Files
new-api-custom/relay/channel/openai/relay_image.go
T
cneicy ceddfd3d6b feat: sync deployed v1.0.0-rc.38-custom.42
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
2026-09-26 04:49:11 +08:00

367 lines
13 KiB
Go

package openai
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// OpenaiImageHandler handles non-streaming OpenAI image responses
// (generations/edits), returning the parsed usage for billing.
func OpenaiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
defer service.CloseResponseBodyGracefully(resp)
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
}
var usageResp dto.SimpleResponse
err = common.Unmarshal(responseBody, &usageResp)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if oaiError := usageResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}
info.UpdateImageCount(openaiImageResponseCount(responseBody))
// 写入新的 response body
service.IOCopyBytesGracefully(c, resp, responseBody)
normalizeOpenAIUsage(&usageResp.Usage)
applyUsagePostProcessing(info, &usageResp.Usage, responseBody)
return &usageResp.Usage, nil
}
// openaiImageResponseCount counts billable images in an OpenAI-format image
// response body. An object-shaped data is one image when it carries url or
// b64_json. For arrays the count is the larger of the url-bearing and the
// b64_json-bearing entry counts: a standard response uses one response_format
// so this equals the entry count, an upstream that splits one image into a url
// entry and a b64_json entry bills once, and entries without any image payload
// bill nothing. A zero result leaves the requested quantity in place because
// UpdateImageCount ignores non-positive counts.
func openaiImageResponseCount(responseBody []byte) int64 {
data := gjson.GetBytes(responseBody, "data")
if data.IsObject() {
if openaiImageDataHasField(data, "url") || openaiImageDataHasField(data, "b64_json") {
return 1
}
return 0
}
if !data.IsArray() {
return 0
}
var urls, b64s int64
data.ForEach(func(_, item gjson.Result) bool {
if openaiImageDataHasField(item, "url") {
urls++
}
if openaiImageDataHasField(item, "b64_json") {
b64s++
}
return true
})
return max(urls, b64s)
}
// openaiImageDataHasField reports whether an image data entry carries a
// non-empty string value for field.
func openaiImageDataHasField(item gjson.Result, field string) bool {
value := item.Get(field)
return value.Type == gjson.String && value.Raw != `""`
}
// normalizeOpenAIUsage maps the OpenAI Images usage shape (input_tokens /
// output_tokens / input_tokens_details / output_tokens_details) onto the
// canonical prompt/completion fields. It is used only on the OpenAI image
// relay paths (generations/edits, streaming and non-streaming): the image
// API never returns prompt_tokens / completion_tokens, so the overwrite (=)
// semantics here are equivalent to the previous additive (+=) behavior while
// avoiding any future double-counting if both field sets are ever populated.
// Do not reuse this on chat/embedding paths without revisiting the overwrite
// semantics.
func normalizeOpenAIUsage(usage *dto.Usage) {
if usage == nil {
return
}
if usage.InputTokens != 0 {
usage.PromptTokens = usage.InputTokens
}
if usage.OutputTokens != 0 {
usage.CompletionTokens = usage.OutputTokens
}
if usage.InputTokensDetails != nil {
usage.PromptTokensDetails = usage.InputTokensDetails.Clone()
}
if usage.OutputTokensDetails != nil {
usage.CompletionTokenDetails = *usage.OutputTokensDetails
}
if usage.TotalTokens == 0 {
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
}
func OpenaiImageStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
if resp == nil || resp.Body == nil {
logger.LogError(c, "invalid image stream response")
return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return OpenaiImageHandler(c, info, resp)
}
if !strings.Contains(contentType, "text/event-stream") {
return openaiImageJSONAsStreamHandler(c, info, resp)
}
// Reuse the shared streaming engine (helper.StreamScannerHandler) so the
// image streaming path gets the same ping keepalive, streaming-timeout
// watchdog, client-disconnect detection, panic recovery and goroutine
// cleanup as every other relay stream. The scanner delivers only the
// "data:" payload, so the SSE "event:" line is rebuilt from the JSON "type"
// field (real OpenAI image events keep event == type).
usage := &dto.Usage{}
var lastStreamData []byte
var completedImages int64
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
raw := common.StringToByteSlice(data)
lastStreamData = raw
if isOpenAIImageStreamErrorEvent(raw) {
// Record the error as a soft error; the scanner drives the final
// EndReason. HasErrors() flags the failure for logging/handling.
sr.Error(fmt.Errorf("%s", extractOpenAIImageStreamErrorMessage(raw)))
}
var chunk struct {
Type string `json:"type"`
Usage dto.Usage `json:"usage"`
}
if err := common.Unmarshal(raw, &chunk); err == nil {
normalizeOpenAIUsage(&chunk.Usage)
if service.ValidUsage(&chunk.Usage) {
usage = &chunk.Usage
}
if chunk.Type == "image_generation.completed" || chunk.Type == "image_edit.completed" {
completedImages++
}
}
if err := writeOpenaiImageStreamChunk(c, raw); err != nil {
sr.Stop(err)
}
})
// StreamScannerHandler consumes the upstream [DONE]; re-emit it so the
// client still receives a terminal data: [DONE].
if info.StreamStatus != nil && info.StreamStatus.EndReason == relaycommon.StreamEndReasonDone {
helper.Done(c)
}
applyUsagePostProcessing(info, usage, lastStreamData)
// Only trust completedImages when upstream finished the stream (done/eof).
// On client-side aborts (client_gone, or handler_stop from a failed client
// write) the counter undercounts what upstream actually generated and
// charged, so keep the requested n — otherwise a client could pay for one
// image by disconnecting right after the first completed event. The abort
// guard only blocks lowering the charge: if completed events already
// exceed the recorded n, bill the higher actual count regardless.
if info.StreamStatus != nil {
upstreamFinished := info.StreamStatus.EndReason == relaycommon.StreamEndReasonDone ||
info.StreamStatus.EndReason == relaycommon.StreamEndReasonEOF
if upstreamFinished || completedImages > int64(info.RequestedImageCount()) {
info.UpdateImageCount(completedImages)
}
}
return usage, nil
}
// writeOpenaiImageStreamChunk rebuilds the SSE frame for an image stream chunk:
// it emits an "event:" line derived from the JSON "type" field (when present)
// followed by the verbatim "data:" payload, mirroring helper.ResponseChunkData.
func writeOpenaiImageStreamChunk(c *gin.Context, data []byte) error {
var payload struct {
Type string `json:"type"`
}
_ = common.Unmarshal(data, &payload)
if eventName := strings.TrimSpace(payload.Type); eventName != "" {
return helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: eventName}, string(data))
}
return helper.StringData(c, string(data))
}
// isOpenAIImageStreamErrorEvent detects upstream error chunks by JSON content
// only ("type" of error/upstream_error, or a non-empty "error" field). The SSE
// "event:" line is not available here: StreamScannerHandler delivers only the
// "data:" payload. A payload carrying just a "message" key is deliberately NOT
// treated as an error to avoid false positives.
func isOpenAIImageStreamErrorEvent(data []byte) bool {
if !json.Valid(data) {
return false
}
var payload struct {
Type string `json:"type"`
Error json.RawMessage `json:"error"`
}
if err := common.Unmarshal(data, &payload); err != nil {
return false
}
payloadType := strings.ToLower(strings.TrimSpace(payload.Type))
return payloadType == "error" || payloadType == "upstream_error" || len(payload.Error) > 0
}
func extractOpenAIImageStreamErrorMessage(data []byte) string {
if len(data) == 0 || !json.Valid(data) {
return "upstream image stream returned error event"
}
var payload struct {
Message string `json:"message"`
Error json.RawMessage `json:"error"`
}
if err := common.Unmarshal(data, &payload); err != nil {
return "upstream image stream returned error event"
}
if msg := strings.TrimSpace(payload.Message); msg != "" {
return msg
}
if len(payload.Error) > 0 {
var nested struct {
Message string `json:"message"`
}
if err := common.Unmarshal(payload.Error, &nested); err == nil {
if msg := strings.TrimSpace(nested.Message); msg != "" {
return msg
}
}
if msg := strings.TrimSpace(common.JsonRawMessageToString(payload.Error)); msg != "" {
return msg
}
}
return "upstream image stream returned error event"
}
func openaiImageJSONAsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
defer service.CloseResponseBodyGracefully(resp)
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
}
// Only decode usage/error. Do not Unmarshal data[] into dto.ImageResponse —
// b64_json values are large and would be copied into Go strings then
// re-marshaled for each SSE event.
var usageResp dto.SimpleResponse
if err := common.Unmarshal(responseBody, &usageResp); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if oaiError := usageResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}
normalizeOpenAIUsage(&usageResp.Usage)
applyUsagePostProcessing(info, &usageResp.Usage, responseBody)
info.UpdateImageCount(openaiImageResponseCount(responseBody))
helper.SetEventStreamHeaders(c)
c.Status(http.StatusOK)
created := gjson.GetBytes(responseBody, "created").Int()
if created == 0 {
created = time.Now().Unix()
}
if info != nil {
info.SetFirstResponseTime()
}
validUsage := service.ValidUsage(&usageResp.Usage)
var usageJSON []byte
if validUsage {
usageJSON, err = common.Marshal(usageResp.Usage)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
}
// gjson.Result.Array returns the element list for an array and a single
// element for an object-shaped data, keeping document-relative indexes so
// the zero-copy field forwarding below stays valid for both shapes.
// Entries without url or b64_json carry no image and are not forwarded.
emitted := 0
for _, image := range gjson.GetBytes(responseBody, "data").Array() {
if !openaiImageDataHasField(image, "url") && !openaiImageDataHasField(image, "b64_json") {
continue
}
payload := []byte(`{"type":"image_generation.completed"}`)
payload, err = sjson.SetBytes(payload, "created_at", created)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if validUsage {
payload, err = sjson.SetRawBytes(payload, "usage", usageJSON)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
}
// b64_json goes last: every sjson.Set* reallocates the whole payload,
// so inserting the large blob after all small fields avoids re-copying
// multi-MB buffers.
for _, field := range []string{"url", "revised_prompt", "b64_json"} {
value := image.Get(field)
if value.Type != gjson.String || value.Raw == `""` {
continue
}
raw := []byte(value.Raw)
if value.Index > 0 {
raw = responseBody[value.Index : value.Index+len(value.Raw)]
}
payload, err = sjson.SetRawBytes(payload, field, raw)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
}
if writeErr := helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: "image_generation.completed"}, string(payload)); writeErr != nil {
if info != nil && info.StreamStatus != nil {
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, writeErr)
}
return &usageResp.Usage, nil
}
emitted++
}
if err := writeOpenaiImageStreamDone(c); err != nil {
if info != nil && info.StreamStatus != nil {
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, err)
}
return &usageResp.Usage, nil
}
if info != nil {
info.ReceivedResponseCount += emitted
if info.StreamStatus == nil {
info.StreamStatus = relaycommon.NewStreamStatus()
}
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonDone, nil)
}
return &usageResp.Usage, nil
}
func writeOpenaiImageStreamDone(c *gin.Context) error {
return helper.StringData(c, "[DONE]")
}