Files
sub2api/backend/internal/service/openai_images_responses.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

1931 lines
64 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"
"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) 真空响应:记录上游诊断摘要到 opslast_event/status/model/body 片段)便于
// 排查,并返回 UpstreamFailoverError 触发重试。因实测为「同账号概率性失败」,优先
// RetryableOnSameAccount 同账号快速重试(默认 3 次,大概率某次正常出图),用尽后
// 由 handler 自然换账号 failoverswitchCount 上限保护),既提高成功率又不无谓
// 消耗其它账号配额。
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),
}
}