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
1931 lines
64 KiB
Go
1931 lines
64 KiB
Go
package service
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"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/tidwall/gjson"
|
||
"github.com/tidwall/sjson"
|
||
)
|
||
|
||
type openAIResponsesImageResult struct {
|
||
Result string
|
||
RevisedPrompt string
|
||
OutputFormat string
|
||
Size string
|
||
Background string
|
||
Quality string
|
||
Model string
|
||
}
|
||
|
||
type OpenAIImagesUpstreamError struct {
|
||
StatusCode int
|
||
ErrorType string
|
||
Code string
|
||
Message string
|
||
Param string
|
||
UpstreamRequestID string
|
||
}
|
||
|
||
func (e *OpenAIImagesUpstreamError) Error() string {
|
||
if e == nil {
|
||
return ""
|
||
}
|
||
code := strings.TrimSpace(e.Code)
|
||
if code == "" {
|
||
code = strings.TrimSpace(e.ErrorType)
|
||
}
|
||
message := strings.TrimSpace(e.Message)
|
||
if code != "" && message != "" {
|
||
return fmt.Sprintf("openai images upstream error: %s: %s", code, message)
|
||
}
|
||
if message != "" {
|
||
return "openai images upstream error: " + message
|
||
}
|
||
if code != "" {
|
||
return "openai images upstream error: " + code
|
||
}
|
||
return "openai images upstream error"
|
||
}
|
||
|
||
func (e *OpenAIImagesUpstreamError) clientStatusCode() int {
|
||
if e == nil {
|
||
return http.StatusBadGateway
|
||
}
|
||
if e.StatusCode > 0 {
|
||
return e.StatusCode
|
||
}
|
||
return http.StatusBadGateway
|
||
}
|
||
|
||
func (e *OpenAIImagesUpstreamError) clientErrorType() string {
|
||
if e == nil {
|
||
return "upstream_error"
|
||
}
|
||
if trimmed := strings.TrimSpace(e.ErrorType); trimmed != "" {
|
||
return trimmed
|
||
}
|
||
return "upstream_error"
|
||
}
|
||
|
||
func (e *OpenAIImagesUpstreamError) clientMessage() string {
|
||
if e == nil {
|
||
return "Upstream request failed"
|
||
}
|
||
if trimmed := strings.TrimSpace(e.Message); trimmed != "" {
|
||
return trimmed
|
||
}
|
||
if trimmed := strings.TrimSpace(e.Code); trimmed != "" {
|
||
return trimmed
|
||
}
|
||
return "Upstream request failed"
|
||
}
|
||
|
||
// IsOpenAIImagesRetryableUpstreamError reports whether an Images error is an
|
||
// upstream server failure that may be retried on another account.
|
||
func IsOpenAIImagesRetryableUpstreamError(err *OpenAIImagesUpstreamError) bool {
|
||
return err != nil && err.StatusCode >= http.StatusInternalServerError
|
||
}
|
||
|
||
func openAIImagesSSEErrorStatus(errType, code string) int {
|
||
errType = strings.ToLower(strings.TrimSpace(errType))
|
||
code = strings.ToLower(strings.TrimSpace(code))
|
||
|
||
switch {
|
||
case strings.Contains(errType, "rate_limit"), strings.Contains(code, "rate_limit"):
|
||
return http.StatusTooManyRequests
|
||
case strings.Contains(errType, "authentication"), strings.Contains(code, "invalid_api_key"), code == "unauthorized":
|
||
return http.StatusUnauthorized
|
||
case strings.Contains(errType, "permission"), code == "forbidden":
|
||
return http.StatusForbidden
|
||
case strings.Contains(errType, "not_found"), strings.Contains(code, "not_found"):
|
||
return http.StatusNotFound
|
||
case strings.Contains(errType, "invalid_request"),
|
||
errType == "image_generation_user_error",
|
||
code == "moderation_blocked",
|
||
strings.Contains(code, "content_policy"),
|
||
strings.Contains(code, "policy_violation"),
|
||
strings.Contains(code, "safety_violation"):
|
||
return http.StatusBadRequest
|
||
default:
|
||
return http.StatusBadGateway
|
||
}
|
||
}
|
||
|
||
func openAIImagesUpstreamErrorResponseBody(err *OpenAIImagesUpstreamError) []byte {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
body := []byte(`{"error":{"type":"","message":""}}`)
|
||
body, _ = sjson.SetBytes(body, "error.type", err.clientErrorType())
|
||
body, _ = sjson.SetBytes(body, "error.message", err.clientMessage())
|
||
if code := strings.TrimSpace(err.Code); code != "" {
|
||
body, _ = sjson.SetBytes(body, "error.code", code)
|
||
}
|
||
if param := strings.TrimSpace(err.Param); param != "" {
|
||
body, _ = sjson.SetBytes(body, "error.param", param)
|
||
}
|
||
return body
|
||
}
|
||
|
||
func openAIResponsesImageResultKey(itemID string, result openAIResponsesImageResult) string {
|
||
if strings.TrimSpace(result.Result) != "" {
|
||
return strings.TrimSpace(result.OutputFormat) + "|" + strings.TrimSpace(result.Result)
|
||
}
|
||
return "item:" + strings.TrimSpace(itemID)
|
||
}
|
||
|
||
func appendOpenAIResponsesImageResultDedup(results *[]openAIResponsesImageResult, seen map[string]struct{}, itemID string, result openAIResponsesImageResult) bool {
|
||
if results == nil {
|
||
return false
|
||
}
|
||
key := openAIResponsesImageResultKey(itemID, result)
|
||
if key != "" {
|
||
if _, exists := seen[key]; exists {
|
||
return false
|
||
}
|
||
seen[key] = struct{}{}
|
||
}
|
||
*results = append(*results, result)
|
||
return true
|
||
}
|
||
|
||
func mergeOpenAIResponsesImageMeta(dst *openAIResponsesImageResult, src openAIResponsesImageResult) {
|
||
if dst == nil {
|
||
return
|
||
}
|
||
if trimmed := strings.TrimSpace(src.OutputFormat); trimmed != "" {
|
||
dst.OutputFormat = trimmed
|
||
}
|
||
if trimmed := strings.TrimSpace(src.Size); trimmed != "" {
|
||
dst.Size = trimmed
|
||
}
|
||
if trimmed := strings.TrimSpace(src.Background); trimmed != "" {
|
||
dst.Background = trimmed
|
||
}
|
||
if trimmed := strings.TrimSpace(src.Quality); trimmed != "" {
|
||
dst.Quality = trimmed
|
||
}
|
||
if trimmed := strings.TrimSpace(src.Model); trimmed != "" {
|
||
dst.Model = trimmed
|
||
}
|
||
}
|
||
|
||
func openAIResponsesImageResultSizes(results []openAIResponsesImageResult) []string {
|
||
if len(results) == 0 {
|
||
return nil
|
||
}
|
||
sizes := make([]string, 0, len(results))
|
||
for _, result := range results {
|
||
if size := strings.TrimSpace(result.Size); size != "" {
|
||
sizes = append(sizes, size)
|
||
}
|
||
}
|
||
if len(sizes) == 0 {
|
||
return nil
|
||
}
|
||
return sizes
|
||
}
|
||
|
||
func extractOpenAIResponsesImageMetaFromLifecycleEvent(payload []byte) (openAIResponsesImageResult, int64, bool) {
|
||
switch gjson.GetBytes(payload, "type").String() {
|
||
case "response.created", "response.in_progress", "response.completed":
|
||
default:
|
||
return openAIResponsesImageResult{}, 0, false
|
||
}
|
||
|
||
response := gjson.GetBytes(payload, "response")
|
||
if !response.Exists() {
|
||
return openAIResponsesImageResult{}, 0, false
|
||
}
|
||
|
||
meta := openAIResponsesImageResult{
|
||
OutputFormat: strings.TrimSpace(response.Get("tools.0.output_format").String()),
|
||
Size: strings.TrimSpace(response.Get("tools.0.size").String()),
|
||
Background: strings.TrimSpace(response.Get("tools.0.background").String()),
|
||
Quality: strings.TrimSpace(response.Get("tools.0.quality").String()),
|
||
Model: strings.TrimSpace(response.Get("tools.0.model").String()),
|
||
}
|
||
return meta, response.Get("created_at").Int(), true
|
||
}
|
||
|
||
func buildOpenAIImagesStreamPartialPayload(
|
||
eventType string,
|
||
b64 string,
|
||
partialImageIndex int64,
|
||
responseFormat string,
|
||
createdAt int64,
|
||
meta openAIResponsesImageResult,
|
||
) []byte {
|
||
if createdAt <= 0 {
|
||
createdAt = time.Now().Unix()
|
||
}
|
||
|
||
payload := []byte(`{"type":"","created_at":0,"partial_image_index":0,"b64_json":""}`)
|
||
payload, _ = sjson.SetBytes(payload, "type", eventType)
|
||
payload, _ = sjson.SetBytes(payload, "created_at", createdAt)
|
||
payload, _ = sjson.SetBytes(payload, "partial_image_index", partialImageIndex)
|
||
payload, _ = sjson.SetBytes(payload, "b64_json", b64)
|
||
if strings.EqualFold(strings.TrimSpace(responseFormat), "url") {
|
||
payload, _ = sjson.SetBytes(payload, "url", "data:"+openAIImageOutputMIMEType(meta.OutputFormat)+";base64,"+b64)
|
||
}
|
||
if meta.Background != "" {
|
||
payload, _ = sjson.SetBytes(payload, "background", meta.Background)
|
||
}
|
||
if meta.OutputFormat != "" {
|
||
payload, _ = sjson.SetBytes(payload, "output_format", meta.OutputFormat)
|
||
}
|
||
if meta.Quality != "" {
|
||
payload, _ = sjson.SetBytes(payload, "quality", meta.Quality)
|
||
}
|
||
if meta.Size != "" {
|
||
payload, _ = sjson.SetBytes(payload, "size", meta.Size)
|
||
}
|
||
if meta.Model != "" {
|
||
payload, _ = sjson.SetBytes(payload, "model", meta.Model)
|
||
}
|
||
return payload
|
||
}
|
||
|
||
func buildOpenAIImagesStreamCompletedPayload(
|
||
eventType string,
|
||
img openAIResponsesImageResult,
|
||
responseFormat string,
|
||
createdAt int64,
|
||
usageRaw []byte,
|
||
) []byte {
|
||
if createdAt <= 0 {
|
||
createdAt = time.Now().Unix()
|
||
}
|
||
|
||
payload := []byte(`{"type":"","created_at":0,"b64_json":""}`)
|
||
payload, _ = sjson.SetBytes(payload, "type", eventType)
|
||
payload, _ = sjson.SetBytes(payload, "created_at", createdAt)
|
||
payload, _ = sjson.SetBytes(payload, "b64_json", img.Result)
|
||
if strings.EqualFold(strings.TrimSpace(responseFormat), "url") {
|
||
payload, _ = sjson.SetBytes(payload, "url", "data:"+openAIImageOutputMIMEType(img.OutputFormat)+";base64,"+img.Result)
|
||
}
|
||
if img.Background != "" {
|
||
payload, _ = sjson.SetBytes(payload, "background", img.Background)
|
||
}
|
||
if img.OutputFormat != "" {
|
||
payload, _ = sjson.SetBytes(payload, "output_format", img.OutputFormat)
|
||
}
|
||
if img.Quality != "" {
|
||
payload, _ = sjson.SetBytes(payload, "quality", img.Quality)
|
||
}
|
||
if img.Size != "" {
|
||
payload, _ = sjson.SetBytes(payload, "size", img.Size)
|
||
}
|
||
if img.Model != "" {
|
||
payload, _ = sjson.SetBytes(payload, "model", img.Model)
|
||
}
|
||
if len(usageRaw) > 0 && gjson.ValidBytes(usageRaw) {
|
||
payload, _ = sjson.SetRawBytes(payload, "usage", usageRaw)
|
||
}
|
||
return payload
|
||
}
|
||
|
||
func openAIImageOutputMIMEType(outputFormat string) string {
|
||
if outputFormat == "" {
|
||
return "image/png"
|
||
}
|
||
if strings.Contains(outputFormat, "/") {
|
||
return outputFormat
|
||
}
|
||
switch strings.ToLower(strings.TrimSpace(outputFormat)) {
|
||
case "png":
|
||
return "image/png"
|
||
case "jpg", "jpeg":
|
||
return "image/jpeg"
|
||
case "webp":
|
||
return "image/webp"
|
||
default:
|
||
return "image/png"
|
||
}
|
||
}
|
||
|
||
func openAIImageUploadToDataURL(upload OpenAIImagesUpload) (string, error) {
|
||
if len(upload.Data) == 0 {
|
||
return "", fmt.Errorf("upload %q is empty", strings.TrimSpace(upload.FileName))
|
||
}
|
||
contentType := strings.TrimSpace(upload.ContentType)
|
||
if contentType == "" {
|
||
contentType = http.DetectContentType(upload.Data)
|
||
}
|
||
return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(upload.Data), nil
|
||
}
|
||
|
||
func buildOpenAIImagesResponsesRequest(parsed *OpenAIImagesRequest, toolModel string) ([]byte, error) {
|
||
if parsed == nil {
|
||
return nil, fmt.Errorf("parsed images request is required")
|
||
}
|
||
prompt := strings.TrimSpace(parsed.Prompt)
|
||
if prompt == "" {
|
||
return nil, fmt.Errorf("prompt is required")
|
||
}
|
||
|
||
inputImages := make([]string, 0, len(parsed.InputImageURLs)+len(parsed.Uploads))
|
||
for _, imageURL := range parsed.InputImageURLs {
|
||
if trimmed := strings.TrimSpace(imageURL); trimmed != "" {
|
||
inputImages = append(inputImages, trimmed)
|
||
}
|
||
}
|
||
for _, upload := range parsed.Uploads {
|
||
dataURL, err := openAIImageUploadToDataURL(upload)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
inputImages = append(inputImages, dataURL)
|
||
}
|
||
if parsed.IsEdits() && len(inputImages) == 0 {
|
||
return nil, fmt.Errorf("image input is required")
|
||
}
|
||
|
||
req := []byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"}}`)
|
||
req, _ = sjson.SetBytes(req, "model", openAIImagesResponsesMainModel)
|
||
|
||
input := []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`)
|
||
input, _ = sjson.SetBytes(input, "0.content.0.text", prompt)
|
||
for index, imageURL := range inputImages {
|
||
part := []byte(`{"type":"input_image","image_url":""}`)
|
||
part, _ = sjson.SetBytes(part, "image_url", imageURL)
|
||
input, _ = sjson.SetRawBytes(input, fmt.Sprintf("0.content.%d", index+1), part)
|
||
}
|
||
req, _ = sjson.SetRawBytes(req, "input", input)
|
||
|
||
action := "generate"
|
||
if parsed.IsEdits() {
|
||
action = "edit"
|
||
}
|
||
tool := []byte(`{"type":"image_generation","action":"","model":""}`)
|
||
tool, _ = sjson.SetBytes(tool, "action", action)
|
||
tool, _ = sjson.SetBytes(tool, "model", strings.TrimSpace(toolModel))
|
||
if shouldPassOpenAIImagesN(toolModel, parsed.N) {
|
||
tool, _ = sjson.SetBytes(tool, "n", parsed.N)
|
||
}
|
||
|
||
for _, field := range []struct {
|
||
path string
|
||
value string
|
||
}{
|
||
{path: "size", value: parsed.Size},
|
||
{path: "quality", value: parsed.Quality},
|
||
{path: "background", value: parsed.Background},
|
||
{path: "output_format", value: parsed.OutputFormat},
|
||
{path: "moderation", value: parsed.Moderation},
|
||
{path: "style", value: parsed.Style},
|
||
} {
|
||
if trimmed := strings.TrimSpace(field.value); trimmed != "" {
|
||
tool, _ = sjson.SetBytes(tool, field.path, trimmed)
|
||
}
|
||
}
|
||
if parsed.OutputCompression != nil {
|
||
tool, _ = sjson.SetBytes(tool, "output_compression", *parsed.OutputCompression)
|
||
}
|
||
if parsed.PartialImages != nil {
|
||
tool, _ = sjson.SetBytes(tool, "partial_images", *parsed.PartialImages)
|
||
}
|
||
|
||
maskImageURL := strings.TrimSpace(parsed.MaskImageURL)
|
||
if parsed.MaskUpload != nil {
|
||
dataURL, err := openAIImageUploadToDataURL(*parsed.MaskUpload)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
maskImageURL = dataURL
|
||
}
|
||
if maskImageURL != "" {
|
||
tool, _ = sjson.SetBytes(tool, "input_image_mask.image_url", maskImageURL)
|
||
}
|
||
|
||
req, _ = sjson.SetRawBytes(req, "tools", []byte(`[]`))
|
||
req, _ = sjson.SetRawBytes(req, "tools.-1", tool)
|
||
return req, nil
|
||
}
|
||
|
||
func shouldPassOpenAIImagesN(model string, n int) bool {
|
||
if n <= 1 {
|
||
return false
|
||
}
|
||
return !strings.EqualFold(strings.TrimSpace(model), "dall-e-3")
|
||
}
|
||
|
||
func extractOpenAIImagesFromResponsesCompleted(payload []byte) ([]openAIResponsesImageResult, int64, []byte, openAIResponsesImageResult, error) {
|
||
if gjson.GetBytes(payload, "type").String() != "response.completed" {
|
||
return nil, 0, nil, openAIResponsesImageResult{}, fmt.Errorf("unexpected event type")
|
||
}
|
||
|
||
createdAt := gjson.GetBytes(payload, "response.created_at").Int()
|
||
if createdAt <= 0 {
|
||
createdAt = time.Now().Unix()
|
||
}
|
||
|
||
var (
|
||
results []openAIResponsesImageResult
|
||
firstMeta openAIResponsesImageResult
|
||
)
|
||
output := gjson.GetBytes(payload, "response.output")
|
||
if output.IsArray() {
|
||
for _, item := range output.Array() {
|
||
if item.Get("type").String() != "image_generation_call" {
|
||
continue
|
||
}
|
||
result := strings.TrimSpace(item.Get("result").String())
|
||
if result == "" {
|
||
continue
|
||
}
|
||
entry := openAIResponsesImageResult{
|
||
Result: result,
|
||
RevisedPrompt: strings.TrimSpace(item.Get("revised_prompt").String()),
|
||
OutputFormat: strings.TrimSpace(item.Get("output_format").String()),
|
||
Size: strings.TrimSpace(item.Get("size").String()),
|
||
Background: strings.TrimSpace(item.Get("background").String()),
|
||
Quality: strings.TrimSpace(item.Get("quality").String()),
|
||
}
|
||
if len(results) == 0 {
|
||
firstMeta = entry
|
||
}
|
||
results = append(results, entry)
|
||
}
|
||
}
|
||
|
||
var usageRaw []byte
|
||
if usage := gjson.GetBytes(payload, "response.tool_usage.image_gen"); usage.Exists() && usage.IsObject() {
|
||
usageRaw = []byte(usage.Raw)
|
||
}
|
||
return results, createdAt, usageRaw, firstMeta, nil
|
||
}
|
||
|
||
func extractOpenAIImageFromResponsesOutputItemDone(payload []byte) (openAIResponsesImageResult, string, bool, error) {
|
||
if gjson.GetBytes(payload, "type").String() != "response.output_item.done" {
|
||
return openAIResponsesImageResult{}, "", false, fmt.Errorf("unexpected event type")
|
||
}
|
||
|
||
item := gjson.GetBytes(payload, "item")
|
||
if !item.Exists() || item.Get("type").String() != "image_generation_call" {
|
||
return openAIResponsesImageResult{}, "", false, nil
|
||
}
|
||
|
||
result := strings.TrimSpace(item.Get("result").String())
|
||
if result == "" {
|
||
return openAIResponsesImageResult{}, "", false, nil
|
||
}
|
||
|
||
entry := openAIResponsesImageResult{
|
||
Result: result,
|
||
RevisedPrompt: strings.TrimSpace(item.Get("revised_prompt").String()),
|
||
OutputFormat: strings.TrimSpace(item.Get("output_format").String()),
|
||
Size: strings.TrimSpace(item.Get("size").String()),
|
||
Background: strings.TrimSpace(item.Get("background").String()),
|
||
Quality: strings.TrimSpace(item.Get("quality").String()),
|
||
}
|
||
return entry, strings.TrimSpace(item.Get("id").String()), true, nil
|
||
}
|
||
|
||
func collectOpenAIImagesFromResponsesBody(body []byte) ([]openAIResponsesImageResult, int64, []byte, openAIResponsesImageResult, bool, error) {
|
||
var (
|
||
fallbackResults []openAIResponsesImageResult
|
||
fallbackSeen = make(map[string]struct{})
|
||
finalResults []openAIResponsesImageResult
|
||
finalMeta openAIResponsesImageResult
|
||
collectErr error
|
||
createdAt int64
|
||
usageRaw []byte
|
||
foundFinal bool
|
||
responseMeta openAIResponsesImageResult
|
||
)
|
||
|
||
forEachOpenAISSEDataPayload(string(body), func(payload []byte) {
|
||
if collectErr != nil || len(finalResults) > 0 {
|
||
return
|
||
}
|
||
if !gjson.ValidBytes(payload) {
|
||
return
|
||
}
|
||
if meta, eventCreatedAt, ok := extractOpenAIResponsesImageMetaFromLifecycleEvent(payload); ok {
|
||
mergeOpenAIResponsesImageMeta(&responseMeta, meta)
|
||
if eventCreatedAt > 0 {
|
||
createdAt = eventCreatedAt
|
||
}
|
||
}
|
||
|
||
switch gjson.GetBytes(payload, "type").String() {
|
||
case "response.output_item.done":
|
||
result, itemID, ok, err := extractOpenAIImageFromResponsesOutputItemDone(payload)
|
||
if err != nil {
|
||
collectErr = err
|
||
return
|
||
}
|
||
if ok {
|
||
mergeOpenAIResponsesImageMeta(&result, responseMeta)
|
||
appendOpenAIResponsesImageResultDedup(&fallbackResults, fallbackSeen, itemID, result)
|
||
}
|
||
case "response.completed":
|
||
results, completedAt, completedUsageRaw, firstMeta, err := extractOpenAIImagesFromResponsesCompleted(payload)
|
||
if err != nil {
|
||
collectErr = err
|
||
return
|
||
}
|
||
foundFinal = true
|
||
if completedAt > 0 {
|
||
createdAt = completedAt
|
||
}
|
||
if len(completedUsageRaw) > 0 {
|
||
usageRaw = completedUsageRaw
|
||
}
|
||
if len(results) > 0 {
|
||
mergeOpenAIResponsesImageMeta(&firstMeta, responseMeta)
|
||
finalResults = results
|
||
finalMeta = firstMeta
|
||
return
|
||
}
|
||
if len(fallbackResults) > 0 {
|
||
firstMeta = fallbackResults[0]
|
||
mergeOpenAIResponsesImageMeta(&firstMeta, responseMeta)
|
||
finalResults = fallbackResults
|
||
finalMeta = firstMeta
|
||
return
|
||
}
|
||
}
|
||
})
|
||
if collectErr != nil {
|
||
return nil, 0, nil, openAIResponsesImageResult{}, false, collectErr
|
||
}
|
||
if len(finalResults) > 0 {
|
||
reconcileOpenAIResponsesImageResultSizes(finalResults, &finalMeta)
|
||
return finalResults, createdAt, usageRaw, finalMeta, true, nil
|
||
}
|
||
|
||
if len(fallbackResults) > 0 {
|
||
firstMeta := fallbackResults[0]
|
||
mergeOpenAIResponsesImageMeta(&firstMeta, responseMeta)
|
||
reconcileOpenAIResponsesImageResultSizes(fallbackResults, &firstMeta)
|
||
return fallbackResults, createdAt, usageRaw, firstMeta, foundFinal, nil
|
||
}
|
||
return nil, createdAt, usageRaw, openAIResponsesImageResult{}, foundFinal, nil
|
||
}
|
||
|
||
func extractOpenAIImagesUpstreamError(body []byte) *OpenAIImagesUpstreamError {
|
||
var upstreamErr *OpenAIImagesUpstreamError
|
||
forEachOpenAISSEDataPayload(string(body), func(payload []byte) {
|
||
if upstreamErr != nil || !gjson.ValidBytes(payload) {
|
||
return
|
||
}
|
||
upstreamErr = openAIImagesUpstreamErrorFromSSEPayload(payload)
|
||
})
|
||
return upstreamErr
|
||
}
|
||
|
||
func openAIImagesUpstreamErrorFromSSEPayload(payload []byte) *OpenAIImagesUpstreamError {
|
||
if !gjson.ValidBytes(payload) {
|
||
return nil
|
||
}
|
||
switch gjson.GetBytes(payload, "type").String() {
|
||
case "error":
|
||
return openAIImagesUpstreamErrorFromGJSON(gjson.GetBytes(payload, "error"), "")
|
||
case "response.failed":
|
||
response := gjson.GetBytes(payload, "response")
|
||
return openAIImagesUpstreamErrorFromGJSON(response.Get("error"), response.Get("id").String())
|
||
case "response.incomplete":
|
||
// 上游在生成预算内未产出图片(超时/被截断),返回 response.incomplete 而非 error。
|
||
// 旧逻辑识别不到,统一报成模糊的 "upstream did not return image output" + 502,
|
||
// 且不触发 failover。这里把它显式建模为可重试的上游错误,使其能换账号重试。
|
||
return openAIImagesIncompleteUpstreamError(gjson.GetBytes(payload, "response"))
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// extractOpenAIImagesModelRefusal 从上游 SSE 响应体提取「模型未出图、改用文字拒绝」
|
||
// 的拒绝文本(内容审核场景)。
|
||
//
|
||
// 上游 response.completed 无图时,模型常以 output_text / message 形式输出拒绝说明
|
||
// (如“被安全系统判定为不适合生成”)。这类失败是内容策略拦截,重试/换账号均无效,
|
||
// 应把该文本作为内容策略错误透传给客户端。返回空串表示无文字输出(真空响应)。
|
||
func extractOpenAIImagesModelRefusal(body []byte) string {
|
||
var b strings.Builder
|
||
collect := func(s string) {
|
||
if s = strings.TrimSpace(s); s != "" {
|
||
if b.Len() > 0 {
|
||
_ = b.WriteByte(' ')
|
||
}
|
||
_, _ = b.WriteString(s)
|
||
}
|
||
}
|
||
forEachOpenAISSEDataPayload(string(body), func(payload []byte) {
|
||
if !gjson.ValidBytes(payload) {
|
||
return
|
||
}
|
||
switch gjson.GetBytes(payload, "type").String() {
|
||
case "response.output_text.delta":
|
||
// 流式文本增量。
|
||
collect(gjson.GetBytes(payload, "delta").String())
|
||
case "response.completed", "response.output_item.done":
|
||
// 终态里的 message/output_text。
|
||
gjson.GetBytes(payload, "response.output").ForEach(func(_, item gjson.Result) bool {
|
||
if item.Get("type").String() == "message" {
|
||
item.Get("content").ForEach(func(_, part gjson.Result) bool {
|
||
if part.Get("type").String() == "output_text" {
|
||
collect(part.Get("text").String())
|
||
}
|
||
return true
|
||
})
|
||
}
|
||
return true
|
||
})
|
||
if item := gjson.GetBytes(payload, "item"); item.Get("type").String() == "message" {
|
||
item.Get("content").ForEach(func(_, part gjson.Result) bool {
|
||
if part.Get("type").String() == "output_text" {
|
||
collect(part.Get("text").String())
|
||
}
|
||
return true
|
||
})
|
||
}
|
||
}
|
||
})
|
||
refusal := strings.TrimSpace(b.String())
|
||
// 截断过长文本,避免把整段模型输出塞进错误响应。
|
||
const maxRefusal = 600
|
||
if len(refusal) > maxRefusal {
|
||
refusal = refusal[:maxRefusal]
|
||
}
|
||
return refusal
|
||
}
|
||
|
||
// summarizeOpenAIImagesNoOutputBody 从上游 SSE 响应体提取诊断摘要,用于软失败时
|
||
// 记录到 ops 日志(上游无图、无标准错误的场景)。提取最终事件类型、response.status、
|
||
// incomplete_details.reason,并附 body 截断片段,便于事后定位上游到底返回了什么。
|
||
func summarizeOpenAIImagesNoOutputBody(body []byte) string {
|
||
var lastType, status, incompleteReason string
|
||
forEachOpenAISSEDataPayload(string(body), func(payload []byte) {
|
||
if !gjson.ValidBytes(payload) {
|
||
return
|
||
}
|
||
if t := strings.TrimSpace(gjson.GetBytes(payload, "type").String()); t != "" {
|
||
lastType = t
|
||
}
|
||
if resp := gjson.GetBytes(payload, "response"); resp.Exists() {
|
||
if s := strings.TrimSpace(resp.Get("status").String()); s != "" {
|
||
status = s
|
||
}
|
||
if r := strings.TrimSpace(resp.Get("incomplete_details.reason").String()); r != "" {
|
||
incompleteReason = r
|
||
}
|
||
}
|
||
})
|
||
var b strings.Builder
|
||
_, _ = b.WriteString("no_image_output")
|
||
if lastType != "" {
|
||
fmt.Fprintf(&b, " last_event=%s", lastType)
|
||
}
|
||
if status != "" {
|
||
fmt.Fprintf(&b, " status=%s", status)
|
||
}
|
||
if incompleteReason != "" {
|
||
fmt.Fprintf(&b, " incomplete_reason=%s", incompleteReason)
|
||
}
|
||
// 附 body 截断片段(脱敏后),上限 1KB,避免日志膨胀。
|
||
snippet := strings.TrimSpace(string(body))
|
||
const maxSnippet = 1024
|
||
if len(snippet) > maxSnippet {
|
||
snippet = snippet[:maxSnippet] + "...(truncated)"
|
||
}
|
||
if snippet != "" {
|
||
fmt.Fprintf(&b, " body=%s", snippet)
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// openAIImagesIncompleteUpstreamError 从 response.incomplete 事件构建可重试的上游错误。
|
||
// incomplete_details.reason 常见取值:max_output_tokens / content_filter 等。
|
||
// content_filter 视为客户端错误(400,重试无意义);其余(生成超时/截断)视为
|
||
// 可重试的 502,触发 failover 换账号重试。
|
||
func openAIImagesIncompleteUpstreamError(response gjson.Result) *OpenAIImagesUpstreamError {
|
||
if !response.Exists() {
|
||
return nil
|
||
}
|
||
reason := strings.TrimSpace(response.Get("incomplete_details.reason").String())
|
||
statusCode := http.StatusBadGateway // 默认可重试(生成未完成)
|
||
errType := "incomplete_error"
|
||
if strings.Contains(strings.ToLower(reason), "content_filter") ||
|
||
strings.Contains(strings.ToLower(reason), "moderation") {
|
||
statusCode = http.StatusBadRequest // 内容过滤,重试无意义
|
||
errType = "image_generation_user_error"
|
||
}
|
||
message := "Upstream did not complete image generation"
|
||
if reason != "" {
|
||
message = fmt.Sprintf("Upstream image generation incomplete: %s", reason)
|
||
}
|
||
return &OpenAIImagesUpstreamError{
|
||
StatusCode: statusCode,
|
||
ErrorType: errType,
|
||
Code: "response_incomplete",
|
||
Message: sanitizeUpstreamErrorMessage(message),
|
||
UpstreamRequestID: strings.TrimSpace(response.Get("id").String()),
|
||
}
|
||
}
|
||
|
||
func openAIImagesUpstreamErrorFromGJSON(errorObj gjson.Result, upstreamRequestID string) *OpenAIImagesUpstreamError {
|
||
if !errorObj.Exists() {
|
||
return nil
|
||
}
|
||
code := strings.TrimSpace(errorObj.Get("code").String())
|
||
errType := strings.TrimSpace(errorObj.Get("type").String())
|
||
message := strings.TrimSpace(errorObj.Get("message").String())
|
||
param := strings.TrimSpace(errorObj.Get("param").String())
|
||
statusCode := openAIImagesSSEErrorStatus(errType, code)
|
||
if message == "" {
|
||
message = "Upstream request failed"
|
||
}
|
||
return &OpenAIImagesUpstreamError{
|
||
StatusCode: statusCode,
|
||
ErrorType: errType,
|
||
Code: code,
|
||
Message: sanitizeUpstreamErrorMessage(message),
|
||
Param: param,
|
||
UpstreamRequestID: strings.TrimSpace(upstreamRequestID),
|
||
}
|
||
}
|
||
|
||
// openAIImagesErrorTypeForStatus returns an OpenAI-style error type when the
|
||
// upstream body does not provide one of its own.
|
||
func openAIImagesErrorTypeForStatus(status int) string {
|
||
switch {
|
||
case status == http.StatusBadRequest:
|
||
return "invalid_request_error"
|
||
case status == http.StatusUnauthorized:
|
||
return "authentication_error"
|
||
case status == http.StatusForbidden:
|
||
return "permission_error"
|
||
case status == http.StatusNotFound:
|
||
return "not_found_error"
|
||
case status == http.StatusTooManyRequests:
|
||
return "rate_limit_error"
|
||
case status >= 500:
|
||
return "api_error"
|
||
default:
|
||
return "upstream_error"
|
||
}
|
||
}
|
||
|
||
// openAIImagesUpstreamErrorFromHTTP builds an OpenAIImagesUpstreamError from a
|
||
// non-2xx upstream HTTP response, preserving the real status code, type, code,
|
||
// message and param so the client sees the actual upstream error instead of a
|
||
// generic 502.
|
||
func openAIImagesUpstreamErrorFromHTTP(statusCode int, header http.Header, body []byte) *OpenAIImagesUpstreamError {
|
||
errType := strings.TrimSpace(gjson.GetBytes(body, "error.type").String())
|
||
code := strings.TrimSpace(extractUpstreamErrorCode(body))
|
||
param := strings.TrimSpace(gjson.GetBytes(body, "error.param").String())
|
||
message := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(body)))
|
||
if message == "" {
|
||
message = fmt.Sprintf("Upstream request failed (status %d)", statusCode)
|
||
}
|
||
if errType == "" {
|
||
errType = openAIImagesErrorTypeForStatus(statusCode)
|
||
}
|
||
requestID := ""
|
||
if header != nil {
|
||
requestID = strings.TrimSpace(header.Get("x-request-id"))
|
||
}
|
||
return &OpenAIImagesUpstreamError{
|
||
StatusCode: statusCode,
|
||
ErrorType: errType,
|
||
Code: code,
|
||
Message: message,
|
||
Param: param,
|
||
UpstreamRequestID: requestID,
|
||
}
|
||
}
|
||
|
||
// handleOpenAIImagesErrorResponse is the non-failover error handler for the
|
||
// images endpoints (/v1/images/generations and /v1/images/edits). Unlike the
|
||
// generic handleErrorResponse — which collapses every non-failover upstream
|
||
// error into a generic 502 "Upstream request failed" — it surfaces the real
|
||
// upstream status code and error message/type/code/param to the client. This
|
||
// mirrors how the Chat Completions and Messages compat paths use
|
||
// handleCompatErrorResponse.
|
||
//
|
||
// It returns an *OpenAIImagesUpstreamError (already written to the client) so
|
||
// the images handler treats it as a terminal user-facing error rather than
|
||
// re-writing a fallback response.
|
||
func (s *OpenAIGatewayService) handleOpenAIImagesErrorResponse(
|
||
ctx context.Context,
|
||
resp *http.Response,
|
||
c *gin.Context,
|
||
account *Account,
|
||
requestedModel ...string,
|
||
) (*OpenAIForwardResult, error) {
|
||
body := s.readUpstreamErrorBody(resp)
|
||
|
||
upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(body)))
|
||
upstreamDetail := ""
|
||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
||
if maxBytes <= 0 {
|
||
maxBytes = 2048
|
||
}
|
||
upstreamDetail = truncateString(string(body), maxBytes)
|
||
}
|
||
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
|
||
|
||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||
logger.LegacyPrintf("service.openai_gateway",
|
||
"OpenAI images upstream error %d (account=%d platform=%s type=%s): %s",
|
||
resp.StatusCode,
|
||
account.ID,
|
||
account.Platform,
|
||
account.Type,
|
||
truncateForLog(body, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes),
|
||
)
|
||
}
|
||
|
||
// Honor admin-configured error passthrough rules first.
|
||
if status, errType, errMsg, matched := applyErrorPassthroughRule(
|
||
c,
|
||
account.Platform,
|
||
resp.StatusCode,
|
||
body,
|
||
http.StatusBadGateway,
|
||
"upstream_error",
|
||
"Upstream request failed",
|
||
); matched {
|
||
upErr := &OpenAIImagesUpstreamError{
|
||
StatusCode: status,
|
||
ErrorType: errType,
|
||
Message: errMsg,
|
||
UpstreamRequestID: strings.TrimSpace(resp.Header.Get("x-request-id")),
|
||
}
|
||
writeOpenAIImagesUpstreamErrorResponse(c, upErr)
|
||
return nil, upErr
|
||
}
|
||
|
||
// If the account is not configured to handle this status code, fall back to
|
||
// a generic gateway error without exposing upstream internals (mirrors
|
||
// handleCompatErrorResponse).
|
||
if !account.ShouldHandleErrorCode(resp.StatusCode) {
|
||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||
Platform: account.Platform,
|
||
AccountID: account.ID,
|
||
AccountName: account.Name,
|
||
UpstreamStatusCode: resp.StatusCode,
|
||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||
Kind: "http_error",
|
||
Message: upstreamMsg,
|
||
Detail: upstreamDetail,
|
||
})
|
||
upErr := &OpenAIImagesUpstreamError{
|
||
StatusCode: http.StatusInternalServerError,
|
||
ErrorType: "upstream_error",
|
||
Message: "Upstream gateway error",
|
||
UpstreamRequestID: strings.TrimSpace(resp.Header.Get("x-request-id")),
|
||
}
|
||
writeOpenAIImagesUpstreamErrorResponse(c, upErr)
|
||
return nil, upErr
|
||
}
|
||
|
||
// Track rate limits / decide whether to disable the account (secondary failover).
|
||
var modelForCooldown string
|
||
if len(requestedModel) > 0 {
|
||
modelForCooldown = strings.TrimSpace(requestedModel[0])
|
||
}
|
||
shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, modelForCooldown)
|
||
kind := "http_error"
|
||
if shouldDisable {
|
||
kind = "failover"
|
||
}
|
||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||
Platform: account.Platform,
|
||
AccountID: account.ID,
|
||
AccountName: account.Name,
|
||
UpstreamStatusCode: resp.StatusCode,
|
||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||
Kind: kind,
|
||
Message: upstreamMsg,
|
||
Detail: upstreamDetail,
|
||
})
|
||
if shouldDisable {
|
||
return nil, &UpstreamFailoverError{
|
||
StatusCode: resp.StatusCode,
|
||
ResponseBody: body,
|
||
RetryableOnSameAccount: false,
|
||
}
|
||
}
|
||
|
||
// Surface the real upstream error to the client.
|
||
upErr := openAIImagesUpstreamErrorFromHTTP(resp.StatusCode, resp.Header, body)
|
||
writeOpenAIImagesUpstreamErrorResponse(c, upErr)
|
||
return nil, upErr
|
||
}
|
||
|
||
func buildOpenAIImagesAPIResponse(
|
||
results []openAIResponsesImageResult,
|
||
createdAt int64,
|
||
usageRaw []byte,
|
||
firstMeta openAIResponsesImageResult,
|
||
responseFormat string,
|
||
) ([]byte, error) {
|
||
if createdAt <= 0 {
|
||
createdAt = time.Now().Unix()
|
||
}
|
||
out := []byte(`{"created":0,"data":[]}`)
|
||
out, _ = sjson.SetBytes(out, "created", createdAt)
|
||
|
||
format := strings.ToLower(strings.TrimSpace(responseFormat))
|
||
if format == "" {
|
||
format = "b64_json"
|
||
}
|
||
for _, img := range results {
|
||
item := []byte(`{}`)
|
||
if format == "url" {
|
||
item, _ = sjson.SetBytes(item, "url", "data:"+openAIImageOutputMIMEType(img.OutputFormat)+";base64,"+img.Result)
|
||
} else {
|
||
item, _ = sjson.SetBytes(item, "b64_json", img.Result)
|
||
}
|
||
if img.RevisedPrompt != "" {
|
||
item, _ = sjson.SetBytes(item, "revised_prompt", img.RevisedPrompt)
|
||
}
|
||
out, _ = sjson.SetRawBytes(out, "data.-1", item)
|
||
}
|
||
if firstMeta.Background != "" {
|
||
out, _ = sjson.SetBytes(out, "background", firstMeta.Background)
|
||
}
|
||
if firstMeta.OutputFormat != "" {
|
||
out, _ = sjson.SetBytes(out, "output_format", firstMeta.OutputFormat)
|
||
}
|
||
if firstMeta.Quality != "" {
|
||
out, _ = sjson.SetBytes(out, "quality", firstMeta.Quality)
|
||
}
|
||
if firstMeta.Size != "" {
|
||
out, _ = sjson.SetBytes(out, "size", firstMeta.Size)
|
||
}
|
||
if firstMeta.Model != "" {
|
||
out, _ = sjson.SetBytes(out, "model", firstMeta.Model)
|
||
}
|
||
if len(usageRaw) > 0 && gjson.ValidBytes(usageRaw) {
|
||
out, _ = sjson.SetRawBytes(out, "usage", usageRaw)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func openAIImagesStreamPrefix(parsed *OpenAIImagesRequest) string {
|
||
if parsed != nil && parsed.IsEdits() {
|
||
return "image_edit"
|
||
}
|
||
return "image_generation"
|
||
}
|
||
|
||
func buildOpenAIImagesStreamErrorBody(message string) []byte {
|
||
body := []byte(`{"type":"error","error":{"type":"upstream_error","message":""}}`)
|
||
if strings.TrimSpace(message) == "" {
|
||
message = "upstream request failed"
|
||
}
|
||
body, _ = sjson.SetBytes(body, "error.message", message)
|
||
return body
|
||
}
|
||
|
||
func buildOpenAIImagesStreamErrorBodyFromUpstream(err *OpenAIImagesUpstreamError) []byte {
|
||
if err == nil {
|
||
return buildOpenAIImagesStreamErrorBody("")
|
||
}
|
||
body := buildOpenAIImagesStreamErrorBody(err.clientMessage())
|
||
body, _ = sjson.SetBytes(body, "error.type", err.clientErrorType())
|
||
if code := strings.TrimSpace(err.Code); code != "" {
|
||
body, _ = sjson.SetBytes(body, "error.code", code)
|
||
}
|
||
if param := strings.TrimSpace(err.Param); param != "" {
|
||
body, _ = sjson.SetBytes(body, "error.param", param)
|
||
}
|
||
return body
|
||
}
|
||
|
||
func writeOpenAIImagesUpstreamErrorResponse(c *gin.Context, err *OpenAIImagesUpstreamError) bool {
|
||
if c == nil || c.Writer == nil || err == nil {
|
||
return false
|
||
}
|
||
if c.Writer.Written() && OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c) >= 0 {
|
||
return false
|
||
}
|
||
StopOpenAIImagesJSONKeepaliveCommitted(c)
|
||
errorObj := gin.H{
|
||
"type": err.clientErrorType(),
|
||
"message": err.clientMessage(),
|
||
}
|
||
if code := strings.TrimSpace(err.Code); code != "" {
|
||
errorObj["code"] = code
|
||
}
|
||
if param := strings.TrimSpace(err.Param); param != "" {
|
||
errorObj["param"] = param
|
||
}
|
||
c.JSON(err.clientStatusCode(), gin.H{
|
||
"error": errorObj,
|
||
})
|
||
return true
|
||
}
|
||
|
||
func (s *OpenAIGatewayService) writeOpenAIImagesStreamEvent(c *gin.Context, flusher http.Flusher, eventName string, payload []byte) error {
|
||
if strings.TrimSpace(eventName) != "" {
|
||
if _, err := fmt.Fprintf(c.Writer, "event: %s\n", eventName); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", payload); err != nil {
|
||
return err
|
||
}
|
||
flusher.Flush()
|
||
return nil
|
||
}
|
||
|
||
func (s *OpenAIGatewayService) tryWriteOpenAIImagesStreamEvent(
|
||
c *gin.Context,
|
||
flusher http.Flusher,
|
||
clientDisconnected *bool,
|
||
lastWriteAt *time.Time,
|
||
eventName string,
|
||
payload []byte,
|
||
) bool {
|
||
if clientDisconnected != nil && *clientDisconnected {
|
||
return false
|
||
}
|
||
if err := s.writeOpenAIImagesStreamEvent(c, flusher, eventName, payload); err != nil {
|
||
if clientDisconnected != nil {
|
||
*clientDisconnected = true
|
||
}
|
||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images stream client disconnected, continue draining upstream for billing")
|
||
return false
|
||
}
|
||
if lastWriteAt != nil {
|
||
*lastWriteAt = time.Now()
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (s *OpenAIGatewayService) parseOpenAIImagesSSEUsageBytes(data []byte, usage *OpenAIUsage) {
|
||
s.parseSSEUsageBytes(data, usage)
|
||
if usage == nil || !gjson.ValidBytes(data) || gjson.GetBytes(data, "type").String() != "response.completed" {
|
||
return
|
||
}
|
||
if toolUsage, ok := openAIImagesToolUsageFromGJSON(gjson.GetBytes(data, "response.tool_usage.image_gen")); ok {
|
||
*usage = toolUsage
|
||
}
|
||
}
|
||
|
||
func openAIImagesToolUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) {
|
||
if !value.Exists() || !value.IsObject() {
|
||
return OpenAIUsage{}, false
|
||
}
|
||
inputTokens, inputOK := boundedJSONNonNegativeInt(value.Get("input_tokens"))
|
||
outputTokens, outputOK := boundedJSONNonNegativeInt(value.Get("output_tokens"))
|
||
imageOutputTokens, imageOutputOK := boundedJSONNonNegativeInt(value.Get("output_tokens_details.image_tokens"))
|
||
if !inputOK || !outputOK || !imageOutputOK {
|
||
return OpenAIUsage{}, false
|
||
}
|
||
return OpenAIUsage{
|
||
InputTokens: inputTokens,
|
||
OutputTokens: outputTokens,
|
||
ImageOutputTokens: imageOutputTokens,
|
||
}, true
|
||
}
|
||
|
||
// boundedJSONNonNegativeInt parses integral JSON exponent notation without
|
||
// invoking an arbitrary-precision parser on an upstream-controlled exponent.
|
||
func boundedJSONNonNegativeInt(value gjson.Result) (int, bool) {
|
||
if !value.Exists() || value.Type != gjson.Number {
|
||
return 0, false
|
||
}
|
||
raw := value.Raw
|
||
if len(raw) == 0 || len(raw) > 64 || raw[0] == '-' {
|
||
return 0, false
|
||
}
|
||
|
||
mantissaEnd := len(raw)
|
||
for i, c := range raw {
|
||
if c != 'e' && c != 'E' {
|
||
continue
|
||
}
|
||
mantissaEnd = i
|
||
break
|
||
}
|
||
|
||
digits := raw[:mantissaEnd]
|
||
fractionDigits := 0
|
||
digitCount := 0
|
||
dotSeen := false
|
||
mantissaIsZero := true
|
||
for _, c := range digits {
|
||
switch {
|
||
case c == '.' && !dotSeen:
|
||
dotSeen = true
|
||
case c >= '0' && c <= '9':
|
||
digitCount++
|
||
mantissaIsZero = mantissaIsZero && c == '0'
|
||
if dotSeen {
|
||
fractionDigits++
|
||
}
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|
||
|
||
exponent := 0
|
||
if mantissaEnd < len(raw) {
|
||
exponentRaw := raw[mantissaEnd+1:]
|
||
negative := false
|
||
if len(exponentRaw) > 0 && (exponentRaw[0] == '+' || exponentRaw[0] == '-') {
|
||
negative = exponentRaw[0] == '-'
|
||
exponentRaw = exponentRaw[1:]
|
||
}
|
||
if len(exponentRaw) == 0 {
|
||
return 0, false
|
||
}
|
||
for len(exponentRaw) > 1 && exponentRaw[0] == '0' {
|
||
exponentRaw = exponentRaw[1:]
|
||
}
|
||
for _, digit := range exponentRaw {
|
||
if digit < '0' || digit > '9' {
|
||
return 0, false
|
||
}
|
||
}
|
||
if mantissaIsZero {
|
||
return 0, true
|
||
}
|
||
if len(exponentRaw) > 3 {
|
||
return 0, false
|
||
}
|
||
for _, digit := range exponentRaw {
|
||
exponent = exponent*10 + int(digit-'0')
|
||
}
|
||
if exponent > 100 {
|
||
return 0, false
|
||
}
|
||
if negative {
|
||
exponent = -exponent
|
||
}
|
||
}
|
||
|
||
trailingZeros := exponent - fractionDigits
|
||
scaleReduction := 0
|
||
if trailingZeros < 0 {
|
||
scaleReduction = -trailingZeros
|
||
remaining := scaleReduction
|
||
allZeros := true
|
||
for i := len(digits) - 1; i >= 0; i-- {
|
||
if digits[i] == '.' {
|
||
continue
|
||
}
|
||
if digits[i] != '0' {
|
||
allZeros = false
|
||
if remaining > 0 {
|
||
return 0, false
|
||
}
|
||
}
|
||
if remaining > 0 {
|
||
remaining--
|
||
}
|
||
}
|
||
if remaining > 0 {
|
||
if allZeros {
|
||
return 0, true
|
||
}
|
||
return 0, false
|
||
}
|
||
}
|
||
|
||
maxInt := int(^uint(0) >> 1)
|
||
parsed := 0
|
||
digitsToAccumulate := digitCount - scaleReduction
|
||
for _, c := range digits {
|
||
if c == '.' {
|
||
continue
|
||
}
|
||
if digitsToAccumulate <= 0 {
|
||
break
|
||
}
|
||
if parsed > (maxInt-int(c-'0'))/10 {
|
||
return 0, false
|
||
}
|
||
parsed = parsed*10 + int(c-'0')
|
||
digitsToAccumulate--
|
||
}
|
||
if trailingZeros < 0 {
|
||
return parsed, true
|
||
}
|
||
for ; trailingZeros > 0; trailingZeros-- {
|
||
if parsed > maxInt/10 {
|
||
return 0, false
|
||
}
|
||
parsed *= 10
|
||
}
|
||
return parsed, true
|
||
}
|
||
|
||
func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse(
|
||
resp *http.Response,
|
||
c *gin.Context,
|
||
responseFormat string,
|
||
fallbackModel string,
|
||
) (OpenAIUsage, int, []string, error) {
|
||
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
|
||
if err != nil {
|
||
if shouldClassifyOpenAIUpstreamStreamReadError(err, c.Request.Context()) {
|
||
err = newOpenAIUpstreamStreamReadError(err)
|
||
}
|
||
return OpenAIUsage{}, 0, nil, err
|
||
}
|
||
|
||
var usage OpenAIUsage
|
||
forEachOpenAISSEDataPayload(string(body), func(data []byte) {
|
||
s.parseOpenAIImagesSSEUsageBytes(data, &usage)
|
||
})
|
||
results, createdAt, usageRaw, firstMeta, _, err := collectOpenAIImagesFromResponsesBody(body)
|
||
if err != nil {
|
||
return OpenAIUsage{}, 0, nil, err
|
||
}
|
||
if len(results) == 0 {
|
||
if upstreamErr := extractOpenAIImagesUpstreamError(body); upstreamErr != nil {
|
||
setOpsUpstreamError(c, upstreamErr.clientStatusCode(), upstreamErr.clientMessage(), "")
|
||
if !IsOpenAIImagesRetryableUpstreamError(upstreamErr) {
|
||
writeOpenAIImagesUpstreamErrorResponse(c, upstreamErr)
|
||
}
|
||
return OpenAIUsage{}, 0, nil, upstreamErr
|
||
}
|
||
// 软失败兜底:上游无图。先区分两种情形(实测真因,见下):
|
||
//
|
||
// (A) 内容审核拒绝:模型未出图,但输出了文字拒绝(response.completed 里带
|
||
// output_text / message,内容如“被安全系统判定为不适合生成”)。这是用户
|
||
// prompt 触发 OpenAI 内容策略,模型主动拒绝改用文字回应。**换账号/重试均无效**
|
||
// (内容层拦截,与账号/承载模型无关),应把拒绝理由作为 400 透传给客户端,
|
||
// 避免无谓地重试 + 消耗其它账号配额,且让客户端拿到可读的拒绝原因。
|
||
// (B) 真空响应:既无图也无任何文字输出(罕见,如偶发路由到 gpt-5.x-mini、
|
||
// image_gen 工具未执行)。这是上游的概率性失败,此时才按可重试处理。
|
||
if refusal := extractOpenAIImagesModelRefusal(body); refusal != "" {
|
||
refusalErr := &OpenAIImagesUpstreamError{
|
||
StatusCode: http.StatusBadRequest,
|
||
ErrorType: "image_generation_user_error",
|
||
Code: "content_policy_violation",
|
||
Message: sanitizeUpstreamErrorMessage(refusal),
|
||
}
|
||
setOpsUpstreamError(c, http.StatusBadRequest, refusalErr.clientMessage(), summarizeOpenAIImagesNoOutputBody(body))
|
||
writeOpenAIImagesUpstreamErrorResponse(c, refusalErr)
|
||
return OpenAIUsage{}, 0, nil, refusalErr
|
||
}
|
||
// (B) 真空响应:记录上游诊断摘要到 ops(last_event/status/model/body 片段)便于
|
||
// 排查,并返回 UpstreamFailoverError 触发重试。因实测为「同账号概率性失败」,优先
|
||
// RetryableOnSameAccount 同账号快速重试(默认 3 次,大概率某次正常出图),用尽后
|
||
// 由 handler 自然换账号 failover(switchCount 上限保护),既提高成功率又不无谓
|
||
// 消耗其它账号配额。
|
||
setOpsUpstreamError(c, http.StatusBadGateway, "upstream did not return image output", summarizeOpenAIImagesNoOutputBody(body))
|
||
return OpenAIUsage{}, 0, nil, &UpstreamFailoverError{
|
||
StatusCode: http.StatusBadGateway,
|
||
ResponseBody: body,
|
||
RetryableOnSameAccount: true,
|
||
}
|
||
}
|
||
if strings.TrimSpace(firstMeta.Model) == "" {
|
||
firstMeta.Model = strings.TrimSpace(fallbackModel)
|
||
}
|
||
|
||
responseBody, err := buildOpenAIImagesAPIResponse(results, createdAt, usageRaw, firstMeta, responseFormat)
|
||
if err != nil {
|
||
return OpenAIUsage{}, 0, nil, err
|
||
}
|
||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||
c.Data(resp.StatusCode, "application/json; charset=utf-8", responseBody)
|
||
return usage, len(results), openAIResponsesImageResultSizes(results), nil
|
||
}
|
||
|
||
func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
|
||
resp *http.Response,
|
||
c *gin.Context,
|
||
startTime time.Time,
|
||
responseFormat string,
|
||
streamPrefix string,
|
||
fallbackModel string,
|
||
) (OpenAIUsage, int, []string, *int, error) {
|
||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||
c.Header("Content-Type", "text/event-stream")
|
||
c.Header("Cache-Control", "no-cache")
|
||
c.Header("Connection", "keep-alive")
|
||
c.Status(resp.StatusCode)
|
||
|
||
flusher, ok := c.Writer.(http.Flusher)
|
||
if !ok {
|
||
return OpenAIUsage{}, 0, nil, nil, fmt.Errorf("streaming is not supported by response writer")
|
||
}
|
||
|
||
format := strings.ToLower(strings.TrimSpace(responseFormat))
|
||
if format == "" {
|
||
format = "b64_json"
|
||
}
|
||
|
||
usage := OpenAIUsage{}
|
||
imageCount := 0
|
||
var imageOutputSizes []string
|
||
var firstTokenMs *int
|
||
emitted := make(map[string]struct{})
|
||
pendingResults := make([]openAIResponsesImageResult, 0, 1)
|
||
pendingSeen := make(map[string]struct{})
|
||
streamMeta := openAIResponsesImageResult{Model: strings.TrimSpace(fallbackModel)}
|
||
var createdAt int64
|
||
clientDisconnected := false
|
||
lastDownstreamWriteAt := time.Now()
|
||
var sseData openAISSEDataAccumulator
|
||
var processDataErr error
|
||
processDataDone := false
|
||
writerSizeBeforeResponse := OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c)
|
||
|
||
processData := func(dataBytes []byte) {
|
||
if processDataDone || processDataErr != nil {
|
||
return
|
||
}
|
||
if firstTokenMs == nil {
|
||
ms := int(time.Since(startTime).Milliseconds())
|
||
firstTokenMs = &ms
|
||
}
|
||
s.parseOpenAIImagesSSEUsageBytes(dataBytes, &usage)
|
||
if !gjson.ValidBytes(dataBytes) {
|
||
return
|
||
}
|
||
if meta, eventCreatedAt, ok := extractOpenAIResponsesImageMetaFromLifecycleEvent(dataBytes); ok {
|
||
mergeOpenAIResponsesImageMeta(&streamMeta, meta)
|
||
if eventCreatedAt > 0 {
|
||
createdAt = eventCreatedAt
|
||
}
|
||
}
|
||
switch gjson.GetBytes(dataBytes, "type").String() {
|
||
case "response.image_generation_call.partial_image":
|
||
b64 := strings.TrimSpace(gjson.GetBytes(dataBytes, "partial_image_b64").String())
|
||
if b64 == "" {
|
||
return
|
||
}
|
||
eventName := streamPrefix + ".partial_image"
|
||
partialMeta := streamMeta
|
||
mergeOpenAIResponsesImageMeta(&partialMeta, openAIResponsesImageResult{
|
||
OutputFormat: strings.TrimSpace(gjson.GetBytes(dataBytes, "output_format").String()),
|
||
Background: strings.TrimSpace(gjson.GetBytes(dataBytes, "background").String()),
|
||
})
|
||
payload := buildOpenAIImagesStreamPartialPayload(
|
||
eventName,
|
||
b64,
|
||
gjson.GetBytes(dataBytes, "partial_image_index").Int(),
|
||
format,
|
||
createdAt,
|
||
partialMeta,
|
||
)
|
||
s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, eventName, payload)
|
||
case "response.output_item.done":
|
||
img, itemID, ok, extractErr := extractOpenAIImageFromResponsesOutputItemDone(dataBytes)
|
||
if extractErr != nil {
|
||
s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(extractErr.Error()))
|
||
processDataErr = extractErr
|
||
processDataDone = true
|
||
return
|
||
}
|
||
if !ok {
|
||
return
|
||
}
|
||
mergeOpenAIResponsesImageMeta(&streamMeta, img)
|
||
mergeOpenAIResponsesImageMeta(&img, streamMeta)
|
||
key := openAIResponsesImageResultKey(itemID, img)
|
||
if _, exists := emitted[key]; exists {
|
||
return
|
||
}
|
||
if _, exists := pendingSeen[key]; exists {
|
||
return
|
||
}
|
||
pendingSeen[key] = struct{}{}
|
||
pendingResults = append(pendingResults, img)
|
||
case "response.completed":
|
||
results, _, usageRaw, firstMeta, extractErr := extractOpenAIImagesFromResponsesCompleted(dataBytes)
|
||
if extractErr != nil {
|
||
s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(extractErr.Error()))
|
||
processDataErr = extractErr
|
||
processDataDone = true
|
||
return
|
||
}
|
||
mergeOpenAIResponsesImageMeta(&streamMeta, firstMeta)
|
||
finalResults := make([]openAIResponsesImageResult, 0, len(results)+len(pendingResults))
|
||
finalSeen := make(map[string]struct{})
|
||
for _, img := range results {
|
||
mergeOpenAIResponsesImageMeta(&img, streamMeta)
|
||
appendOpenAIResponsesImageResultDedup(&finalResults, finalSeen, "", img)
|
||
}
|
||
for _, img := range pendingResults {
|
||
mergeOpenAIResponsesImageMeta(&img, streamMeta)
|
||
appendOpenAIResponsesImageResultDedup(&finalResults, finalSeen, "", img)
|
||
}
|
||
reconcileOpenAIResponsesImageResultSizes(finalResults, nil)
|
||
if len(finalResults) == 0 {
|
||
outputErr := fmt.Errorf("upstream did not return image output")
|
||
// 软失败:response.completed 事件里没有图片。记录上游诊断摘要到 ops,
|
||
// 与非流式路径保持一致,避免上游响应信息丢失。
|
||
setOpsUpstreamError(c, http.StatusBadGateway, "upstream did not return image output", summarizeOpenAIImagesNoOutputBody(dataBytes))
|
||
s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(outputErr.Error()))
|
||
processDataErr = outputErr
|
||
processDataDone = true
|
||
return
|
||
}
|
||
eventName := streamPrefix + ".completed"
|
||
for _, img := range finalResults {
|
||
key := openAIResponsesImageResultKey("", img)
|
||
if _, exists := emitted[key]; exists {
|
||
continue
|
||
}
|
||
payload := buildOpenAIImagesStreamCompletedPayload(eventName, img, format, createdAt, usageRaw)
|
||
emitted[key] = struct{}{}
|
||
s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, eventName, payload)
|
||
}
|
||
imageCount = len(emitted)
|
||
imageOutputSizes = openAIResponsesImageResultSizes(finalResults)
|
||
processDataDone = true
|
||
case "error", "response.failed":
|
||
if upstreamErr := openAIImagesUpstreamErrorFromSSEPayload(dataBytes); upstreamErr != nil {
|
||
retryable := IsOpenAIImagesRetryableUpstreamError(upstreamErr)
|
||
if !clientDisconnected && (!retryable || c.Writer.Size() != writerSizeBeforeResponse) {
|
||
s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBodyFromUpstream(upstreamErr))
|
||
}
|
||
setOpsUpstreamError(c, upstreamErr.clientStatusCode(), upstreamErr.clientMessage(), "")
|
||
processDataErr = upstreamErr
|
||
processDataDone = true
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
processLine := func(line []byte) (bool, error) {
|
||
if len(line) == 0 {
|
||
return false, nil
|
||
}
|
||
sseData.AddLine(string(line), processData)
|
||
if processDataErr != nil {
|
||
return true, processDataErr
|
||
}
|
||
return processDataDone, nil
|
||
}
|
||
|
||
flushData := func() (bool, error) {
|
||
sseData.Flush(processData)
|
||
if processDataErr != nil {
|
||
return true, processDataErr
|
||
}
|
||
return processDataDone, nil
|
||
}
|
||
|
||
finalizePending := func() error {
|
||
if imageCount > 0 {
|
||
return nil
|
||
}
|
||
if len(pendingResults) > 0 {
|
||
eventName := streamPrefix + ".completed"
|
||
finalResults := append([]openAIResponsesImageResult(nil), pendingResults...)
|
||
for i := range finalResults {
|
||
mergeOpenAIResponsesImageMeta(&finalResults[i], streamMeta)
|
||
}
|
||
reconcileOpenAIResponsesImageResultSizes(finalResults, nil)
|
||
for _, img := range finalResults {
|
||
key := openAIResponsesImageResultKey("", img)
|
||
if _, exists := emitted[key]; exists {
|
||
continue
|
||
}
|
||
payload := buildOpenAIImagesStreamCompletedPayload(eventName, img, format, createdAt, nil)
|
||
emitted[key] = struct{}{}
|
||
s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, eventName, payload)
|
||
}
|
||
imageCount = len(emitted)
|
||
imageOutputSizes = openAIResponsesImageResultSizes(finalResults)
|
||
return nil
|
||
}
|
||
|
||
streamErr := fmt.Errorf("stream disconnected before image generation completed")
|
||
s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(streamErr.Error()))
|
||
return streamErr
|
||
}
|
||
|
||
streamInterval := s.openAIImageStreamDataInterval()
|
||
keepaliveInterval := s.openAIImageStreamKeepaliveInterval()
|
||
if streamInterval <= 0 && keepaliveInterval <= 0 {
|
||
reader := bufio.NewReader(resp.Body)
|
||
for {
|
||
line, err := reader.ReadBytes('\n')
|
||
done, processErr := processLine(line)
|
||
if processErr != nil {
|
||
return usage, imageCount, imageOutputSizes, firstTokenMs, processErr
|
||
}
|
||
if done {
|
||
return usage, imageCount, imageOutputSizes, firstTokenMs, nil
|
||
}
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
if done, processErr := flushData(); processErr != nil {
|
||
return usage, imageCount, imageOutputSizes, firstTokenMs, processErr
|
||
} else if done {
|
||
return usage, imageCount, imageOutputSizes, firstTokenMs, nil
|
||
}
|
||
if shouldClassifyOpenAIUpstreamStreamReadError(err, c.Request.Context()) {
|
||
err = newOpenAIUpstreamStreamReadError(err)
|
||
}
|
||
return usage, imageCount, imageOutputSizes, firstTokenMs, err
|
||
}
|
||
}
|
||
if done, processErr := flushData(); processErr != nil {
|
||
return usage, imageCount, imageOutputSizes, firstTokenMs, processErr
|
||
} else if done {
|
||
return usage, imageCount, imageOutputSizes, firstTokenMs, nil
|
||
}
|
||
if err := finalizePending(); err != nil {
|
||
return usage, imageCount, imageOutputSizes, firstTokenMs, err
|
||
}
|
||
return usage, imageCount, imageOutputSizes, 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 {
|
||
if done, processErr := flushData(); processErr != nil {
|
||
return usage, imageCount, imageOutputSizes, firstTokenMs, processErr
|
||
} else if done {
|
||
return usage, imageCount, imageOutputSizes, firstTokenMs, nil
|
||
}
|
||
if err := finalizePending(); err != nil {
|
||
return usage, imageCount, imageOutputSizes, firstTokenMs, err
|
||
}
|
||
return usage, imageCount, imageOutputSizes, firstTokenMs, nil
|
||
}
|
||
if ev.err != nil {
|
||
if done, processErr := flushData(); processErr != nil {
|
||
return usage, imageCount, imageOutputSizes, firstTokenMs, processErr
|
||
} else if done {
|
||
return usage, imageCount, imageOutputSizes, firstTokenMs, nil
|
||
}
|
||
if shouldClassifyOpenAIUpstreamStreamReadError(ev.err, c.Request.Context()) {
|
||
ev.err = newOpenAIUpstreamStreamReadError(ev.err)
|
||
}
|
||
return usage, imageCount, imageOutputSizes, firstTokenMs, ev.err
|
||
}
|
||
done, processErr := processLine(ev.line)
|
||
if processErr != nil {
|
||
return usage, imageCount, imageOutputSizes, firstTokenMs, processErr
|
||
}
|
||
if done {
|
||
return usage, imageCount, imageOutputSizes, firstTokenMs, nil
|
||
}
|
||
case <-intervalCh:
|
||
lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
|
||
if time.Since(lastRead) < streamInterval {
|
||
continue
|
||
}
|
||
if clientDisconnected {
|
||
return usage, imageCount, imageOutputSizes, firstTokenMs, fmt.Errorf("image stream incomplete after timeout")
|
||
}
|
||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images responses stream data interval timeout: interval=%s", streamInterval)
|
||
s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(fmt.Sprintf("upstream image stream idle for %s", streamInterval)))
|
||
return usage, imageCount, imageOutputSizes, 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 responses stream client disconnected during keepalive, continue draining upstream for billing")
|
||
continue
|
||
}
|
||
flusher.Flush()
|
||
lastDownstreamWriteAt = time.Now()
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
|
||
ctx context.Context,
|
||
c *gin.Context,
|
||
account *Account,
|
||
parsed *OpenAIImagesRequest,
|
||
channelMappedModel string,
|
||
) (*OpenAIForwardResult, error) {
|
||
startTime := time.Now()
|
||
requestModel := strings.TrimSpace(parsed.Model)
|
||
if mapped := strings.TrimSpace(channelMappedModel); mapped != "" {
|
||
requestModel = mapped
|
||
}
|
||
if requestModel == "" {
|
||
requestModel = "gpt-image-2"
|
||
}
|
||
if err := validateOpenAIImagesModel(requestModel); err != nil {
|
||
return nil, err
|
||
}
|
||
logger.LegacyPrintf(
|
||
"service.openai_gateway",
|
||
"[OpenAI] Images request routing request_model=%s endpoint=%s account_type=%s uploads=%d",
|
||
requestModel,
|
||
parsed.Endpoint,
|
||
account.Type,
|
||
len(parsed.Uploads),
|
||
)
|
||
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
|
||
defer releaseUpstreamCtx()
|
||
|
||
token, _, err := s.GetAccessToken(upstreamCtx, account)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
responsesBody, err := buildOpenAIImagesResponsesRequest(parsed, requestModel)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, responsesBody, token, true, parsed.StickySessionSeed(), false)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
upstreamReq.Header.Set("Content-Type", "application/json")
|
||
upstreamReq.Header.Set("Accept", "text/event-stream")
|
||
|
||
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)
|
||
if !agentIdentityTaskRecoveryWasTried(ctx) && s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, respBody) {
|
||
expectedTaskID := account.GetCredential("task_id")
|
||
if err := s.recoverAgentIdentityTask(ctx, account, expectedTaskID); err != nil {
|
||
return nil, fmt.Errorf("agent identity task recovery failed: %w", err)
|
||
}
|
||
return s.forwardOpenAIImagesOAuth(markAgentIdentityTaskRecoveryTried(ctx), c, account, parsed, channelMappedModel)
|
||
}
|
||
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, requestModel)
|
||
return nil, &UpstreamFailoverError{
|
||
StatusCode: resp.StatusCode,
|
||
ResponseBody: respBody,
|
||
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||
}
|
||
}
|
||
return s.handleOpenAIImagesErrorResponse(upstreamCtx, resp, c, account, requestModel)
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
|
||
var (
|
||
usage OpenAIUsage
|
||
imageCount int
|
||
imageOutputSizes []string
|
||
firstTokenMs *int
|
||
)
|
||
// 与 handleOpenAIImagesOAuthResponseError 的比较端同口径:排除非流式 JSON
|
||
// keepalive 心跳字节,避免 failover 第 2 轮起把上一轮心跳残留误判为已写响应。
|
||
writerSizeBeforeResponse := OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c)
|
||
if parsed.Stream {
|
||
usage, imageCount, imageOutputSizes, firstTokenMs, err = s.handleOpenAIImagesOAuthStreamingResponse(resp, c, startTime, parsed.ResponseFormat, openAIImagesStreamPrefix(parsed), requestModel)
|
||
if err != nil {
|
||
if imageCount > 0 {
|
||
return &OpenAIForwardResult{
|
||
RequestID: resp.Header.Get("x-request-id"),
|
||
Usage: usage,
|
||
Model: requestModel,
|
||
UpstreamModel: requestModel,
|
||
Stream: parsed.Stream,
|
||
ResponseHeaders: resp.Header.Clone(),
|
||
Duration: time.Since(startTime),
|
||
FirstTokenMs: firstTokenMs,
|
||
ImageCount: imageCount,
|
||
ImageSize: parsed.SizeTier,
|
||
ImageInputSize: parsed.Size,
|
||
ImageOutputSizes: imageOutputSizes,
|
||
}, err
|
||
}
|
||
return nil, s.handleOpenAIImagesOAuthResponseError(
|
||
upstreamCtx,
|
||
c,
|
||
account,
|
||
requestModel,
|
||
safeUpstreamURL(upstreamReq.URL.String()),
|
||
resp,
|
||
writerSizeBeforeResponse,
|
||
err,
|
||
)
|
||
}
|
||
} else {
|
||
usage, imageCount, imageOutputSizes, err = s.handleOpenAIImagesOAuthNonStreamingResponse(resp, c, parsed.ResponseFormat, requestModel)
|
||
if err != nil {
|
||
return nil, s.handleOpenAIImagesOAuthResponseError(
|
||
upstreamCtx,
|
||
c,
|
||
account,
|
||
requestModel,
|
||
safeUpstreamURL(upstreamReq.URL.String()),
|
||
resp,
|
||
writerSizeBeforeResponse,
|
||
err,
|
||
)
|
||
}
|
||
}
|
||
if imageCount <= 0 {
|
||
imageCount = parsed.N
|
||
}
|
||
return &OpenAIForwardResult{
|
||
RequestID: resp.Header.Get("x-request-id"),
|
||
Usage: usage,
|
||
Model: requestModel,
|
||
UpstreamModel: requestModel,
|
||
Stream: parsed.Stream,
|
||
ResponseHeaders: resp.Header.Clone(),
|
||
Duration: time.Since(startTime),
|
||
FirstTokenMs: firstTokenMs,
|
||
ImageCount: imageCount,
|
||
ImageSize: parsed.SizeTier,
|
||
ImageInputSize: parsed.Size,
|
||
ImageOutputSizes: imageOutputSizes,
|
||
}, nil
|
||
}
|
||
|
||
func (s *OpenAIGatewayService) handleOpenAIImagesOAuthResponseError(
|
||
ctx context.Context,
|
||
c *gin.Context,
|
||
account *Account,
|
||
requestedModel string,
|
||
upstreamURL string,
|
||
resp *http.Response,
|
||
writerSizeBeforeResponse int,
|
||
err error,
|
||
) error {
|
||
responseWritten := c != nil && c.Writer != nil && OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c) != writerSizeBeforeResponse
|
||
if code, message, ok := OpenAIUpstreamStreamReadErrorDetails(err); ok {
|
||
// A body transport failure after a successful HTTP status is retryable only
|
||
// until real image output has reached the client. Keep the upstream headers
|
||
// and request ID available to the failover/error passthrough path.
|
||
headers := http.Header(nil)
|
||
requestID := ""
|
||
statusCode := http.StatusBadGateway
|
||
if resp != nil {
|
||
headers = resp.Header.Clone()
|
||
requestID = strings.TrimSpace(resp.Header.Get("x-request-id"))
|
||
}
|
||
kind := "failover"
|
||
if responseWritten {
|
||
kind = "retry_exhausted_failover"
|
||
}
|
||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||
Platform: account.Platform, AccountID: account.ID, AccountName: account.Name,
|
||
UpstreamStatusCode: statusCode, UpstreamRequestID: requestID, UpstreamURL: upstreamURL,
|
||
Kind: kind, Message: message,
|
||
})
|
||
if responseWritten {
|
||
return err
|
||
}
|
||
responseBody := []byte(fmt.Sprintf(`{"error":{"type":"upstream_error","code":%q,"message":%q}}`, code, message))
|
||
shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, statusCode, headers, responseBody, requestedModel)
|
||
return &UpstreamFailoverError{StatusCode: statusCode, ResponseBody: responseBody, ResponseHeaders: headers,
|
||
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode)}
|
||
}
|
||
var upstreamErr *OpenAIImagesUpstreamError
|
||
if !errors.As(err, &upstreamErr) {
|
||
return err
|
||
}
|
||
|
||
retryable := IsOpenAIImagesRetryableUpstreamError(upstreamErr)
|
||
kind := "http_error"
|
||
if retryable {
|
||
kind = "failover"
|
||
if responseWritten {
|
||
kind = "retry_exhausted_failover"
|
||
}
|
||
}
|
||
|
||
requestID := strings.TrimSpace(upstreamErr.UpstreamRequestID)
|
||
headers := http.Header(nil)
|
||
if resp != nil {
|
||
headers = resp.Header.Clone()
|
||
if requestID == "" {
|
||
requestID = strings.TrimSpace(resp.Header.Get("x-request-id"))
|
||
}
|
||
}
|
||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||
Platform: account.Platform,
|
||
AccountID: account.ID,
|
||
AccountName: account.Name,
|
||
UpstreamStatusCode: upstreamErr.StatusCode,
|
||
UpstreamRequestID: requestID,
|
||
UpstreamURL: upstreamURL,
|
||
Kind: kind,
|
||
Message: upstreamErr.clientMessage(),
|
||
})
|
||
|
||
if !retryable || responseWritten {
|
||
return err
|
||
}
|
||
|
||
responseBody := openAIImagesUpstreamErrorResponseBody(upstreamErr)
|
||
shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, upstreamErr.StatusCode, headers, responseBody, requestedModel)
|
||
return &UpstreamFailoverError{
|
||
StatusCode: upstreamErr.StatusCode,
|
||
ResponseBody: responseBody,
|
||
ResponseHeaders: headers,
|
||
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(upstreamErr.StatusCode),
|
||
}
|
||
}
|