Merge pull request #6890 from slovx2/feat/openai-images-75be

feat(openai): route OAuth image requests through native Codex Images
This commit is contained in:
Wesley Liddick
2026-09-11 17:29:54 +08:00
committed by GitHub
20 changed files with 897 additions and 130 deletions
@@ -157,7 +157,7 @@ func TestOpenAIGatewayHandlerImages_ServerErrorFailsOverAndReturnsClearErrorWhen
)
handler.maxAccountSwitches = 10
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","quality":"high","size":"1536x1024"}`)
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","quality":"high","size":"1536x1024"}`)
core, observedLogs := observer.New(zap.DebugLevel)
requestCtx := logger.IntoContext(context.Background(), zap.New(core))
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)).WithContext(requestCtx)
@@ -3068,7 +3068,7 @@ func (s *AccountTestService) testOpenAIImageAPIKey(c *gin.Context, ctx context.C
return nil
}
// testOpenAIImageOAuth tests OpenAI image generation using an OAuth account via Codex /responses API.
// OAuth 图片测试与正式转发共用 Codex Images / Responses 分流规则。
func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Context, account *Account, modelID, prompt string) error {
credentialAccount := account
if account.IsShadow() {
@@ -3094,7 +3094,6 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
c.Writer.Flush()
s.sendEvent(c, TestEvent{Type: "test_start", Model: modelID})
s.sendEvent(c, TestEvent{Type: "content", Text: "Calling Codex /responses image tool...\n"})
parsed := &OpenAIImagesRequest{
Endpoint: openAIImagesGenerationsEndpoint,
@@ -3103,14 +3102,19 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
}
applyOpenAIImagesDefaults(parsed)
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Responses driver: %s; image model: %s\n", openAIImagesResponsesMainModelValue(), parsed.Model)})
responsesBody, err := buildOpenAIImagesResponsesRequest(parsed, parsed.Model)
upstreamModel := account.GetMappedModel(parsed.Model)
responsesBody, targetURL, err := buildOpenAIImagesOAuthPayload(parsed, upstreamModel)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to build image request: %s", err.Error()))
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatgptCodexAPIURL, bytes.NewReader(responsesBody))
direct := usesCodexDirectImages(upstreamModel)
if direct {
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Calling Codex /images/generations; image model: %s\n", upstreamModel)})
} else {
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Calling Codex /responses image tool; driver: %s; image model: %s\n", openAIImagesResponsesMainModelValue(), upstreamModel)})
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(responsesBody))
if err != nil {
return s.sendErrorAndEnd(c, "Failed to create request")
}
@@ -3132,6 +3136,10 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("OpenAI-Beta", "responses=experimental")
if direct {
req.Header.Del("OpenAI-Beta")
req.Header.Set("Accept", "application/json")
}
canonical := resolveCodexOutboundIdentity("")
req.Header.Set("originator", canonical.originator)
if customUA := strings.TrimSpace(credentialAccount.GetOpenAIUserAgent()); customUA != "" {
@@ -3150,7 +3158,7 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
}
resp, err := s.doOpenAIAccountTestUpstream(req, proxyURL, account, false)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Responses API request failed: %s", err.Error()))
return s.sendErrorAndEnd(c, fmt.Sprintf("Image upstream request failed: %s", err.Error()))
}
defer func() {
if resp != nil && resp.Body != nil {
@@ -3162,7 +3170,7 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
body = redactAgentIdentitySensitiveBodyForAccount(ctx, s.accountRepo, credentialAccount, body)
message := strings.TrimSpace(extractUpstreamErrorMessage(body))
if message == "" {
message = fmt.Sprintf("Responses API returned %d", resp.StatusCode)
message = fmt.Sprintf("Image upstream returned %d", resp.StatusCode)
}
return s.sendErrorAndEnd(c, message)
}
@@ -3173,18 +3181,26 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
}
body = redactAgentIdentitySensitiveBodyForAccount(ctx, s.accountRepo, credentialAccount, body)
results, _, _, _, _, err := collectOpenAIImagesFromResponsesBody(body)
var results []openAIResponsesImageResult
if direct {
results, err = parseCodexDirectImagesResponse(body)
} else {
if upstreamErr := extractOpenAIImagesUpstreamError(body); upstreamErr != nil {
return s.sendErrorAndEnd(c, upstreamErr.clientMessage())
}
results, _, _, _, _, err = collectOpenAIImagesFromResponsesBody(body)
}
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to parse image response: %s", err.Error()))
}
if len(results) == 0 {
if upstreamErr := extractOpenAIImagesUpstreamError(body); upstreamErr != nil {
return s.sendErrorAndEnd(c, upstreamErr.clientMessage())
}
if textErr := openAIImagesTextFallbackError(body); textErr != nil {
return s.sendErrorAndEnd(c, textErr.clientMessage())
}
return s.sendErrorAndEnd(c, "No images returned from responses API")
if direct {
return s.sendErrorAndEnd(c, "No images returned from Codex Images API")
}
return s.sendErrorAndEnd(c, "No images returned from Codex Responses API")
}
for _, item := range results {
@@ -11,11 +11,9 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAccountTestService_OpenAIImageOAuthHandlesOutputItemDoneFallback(t *testing.T) {
t.Setenv("SUB2API_IMAGES_MAIN_MODEL", "gpt-5.6-sol")
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -45,11 +43,9 @@ func TestAccountTestService_OpenAIImageOAuthHandlesOutputItemDoneFallback(t *tes
},
}
err := svc.testOpenAIImageOAuth(c, context.Background(), account, "gpt-image-2.5-flare", "draw a cat")
err := svc.testOpenAIImageOAuth(c, context.Background(), account, "gpt-image-1", "draw a cat")
require.NoError(t, err)
require.NotNil(t, upstream.lastReq)
require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "gpt-image-2.5-flare", gjson.GetBytes(upstream.lastBody, "tools.0.model").String())
require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context()))
require.Contains(t, rec.Body.String(), "Calling Codex /responses image tool")
require.Contains(t, rec.Body.String(), "data:image/png;base64,aGVsbG8=")
@@ -95,18 +91,3 @@ func TestAccountTestService_OpenAIImageAPIKeyUsesConfiguredV1BaseURL(t *testing.
require.Contains(t, rec.Body.String(), "data:image/png;base64,aGVsbG8=")
require.Contains(t, rec.Body.String(), "\"success\":true")
}
func TestAccountTestService_OpenAIImageOAuthSurfacesSSEError(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/1/test", nil)
svc := &AccountTestService{httpUpstream: &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader("data: {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The selected image model is unavailable\"}}\n\n")),
}}}
account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"access_token": "test"}}
err := svc.testOpenAIImageOAuth(c, context.Background(), account, "gpt-image-2.5-flare", "draw a cup")
require.ErrorContains(t, err, "The selected image model is unavailable")
require.NotContains(t, rec.Body.String(), "No images returned")
}
@@ -96,6 +96,7 @@ type ModelPricing struct {
InputPricePerToken float64 // 每token输入价格 (USD)
InputPricePerTokenPriority float64 // priority service tier 下每token输入价格 (USD)
ImageInputPricePerToken float64 // 图片输入 token 价格 (USD),用于多模态 embedding 等图文不同价场景;为 0 时回退到 InputPricePerToken
ImageCacheReadPricePerToken float64 // 图片缓存输入价格;无独立价格时沿用缓存读取价
OutputPricePerToken float64 // 每token输出价格 (USD)
OutputPricePerTokenPriority float64 // priority service tier 下每token输出价格 (USD)
CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD)
@@ -179,6 +180,7 @@ func pricingWithPriorityMultiplier(base *ModelPricing, multiplier float64) *Mode
type UsageTokens struct {
InputTokens int
ImageInputTokens int
ImageCacheReadTokens int
OutputTokens int
CacheCreationTokens int
CacheReadTokens int
@@ -1273,6 +1275,7 @@ func (s *BillingService) getModelPricingAt(model string, pricingAt time.Time) (*
LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier,
LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier,
ImageInputPricePerToken: litellmPricing.InputCostPerImageToken,
ImageCacheReadPricePerToken: litellmPricing.CacheReadInputImageTokenCost,
ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken,
}, true, pricingAt), nil
}
@@ -1595,6 +1598,9 @@ func (s *BillingService) computeTokenBreakdown(
bd.CacheCreationCost = s.computeCacheCreationCost(pricing, tokens, cacheCreationPrice, cacheCreationMultiplier)
bd.CacheReadCost = float64(tokens.CacheReadTokens) * cacheReadPrice
if imageCached := min(max(tokens.ImageCacheReadTokens, 0), max(tokens.CacheReadTokens, 0)); imageCached > 0 && pricing.ImageCacheReadPricePerToken > 0 {
bd.CacheReadCost = float64(tokens.CacheReadTokens-imageCached)*cacheReadPrice + float64(imageCached)*pricing.ImageCacheReadPricePerToken
}
if tierMultiplier != 1.0 {
bd.InputCost *= tierMultiplier
@@ -74,7 +74,7 @@ func (c *openAIImageOutputCounter) AddSSEData(data []byte) {
c.addImageOutputItem(root.Get("item"))
case "response.completed", "response.done":
c.addOutputArray(root.Get("response.output"))
case "image_generation.completed":
case "image_generation.completed", "image_edit.completed":
if item := root.Get("item"); item.Exists() {
c.addImageOutputItem(item)
return
@@ -138,7 +138,7 @@ func (c *openAIImageOutputCounter) addImageOutputItem(item gjson.Result) {
return
}
itemType := strings.TrimSpace(item.Get("type").String())
if itemType != "" && itemType != "image_generation_call" && itemType != "image_generation.completed" {
if itemType != "" && itemType != "image_generation_call" && itemType != "image_generation.completed" && itemType != "image_edit.completed" {
return
}
if strings.Contains(strings.ToLower(item.Raw), "partial_image") {
@@ -2131,8 +2131,11 @@ func TestOpenAIGatewayServiceRecordUsage_OutputImageSizeWinsBeforeBillingAndPers
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
RequestID: "resp_image_output_size",
Model: "gpt-image-2",
RequestID: "resp_image_output_size",
Model: "gpt-image-2",
Usage: OpenAIUsage{
ImageCacheReadTokens: 40,
},
ImageCount: 1,
ImageInputSize: "1024x1024",
ImageOutputSizes: []string{"3840x2160"},
@@ -2162,7 +2165,7 @@ func TestOpenAIGatewayServiceRecordUsage_OutputImageSizeWinsBeforeBillingAndPers
require.Equal(t, "3840x2160", *usageRepo.lastLog.ImageOutputSize)
require.NotNil(t, usageRepo.lastLog.ImageSizeSource)
require.Equal(t, ImageSizeSourceOutput, *usageRepo.lastLog.ImageSizeSource)
require.Equal(t, map[string]int{ImageBillingSize4K: 1}, usageRepo.lastLog.ImageSizeBreakdown)
require.Equal(t, map[string]int{ImageBillingSize4K: 1, "image_cache_read_tokens": 40}, usageRepo.lastLog.ImageSizeBreakdown)
require.InDelta(t, 0.44, usageRepo.lastLog.TotalCost, 1e-12)
require.InDelta(t, 0.44, usageRepo.lastLog.ActualCost, 1e-12)
}
@@ -1239,6 +1239,9 @@ func mergeOpenAIUsageNonZero(dst *OpenAIUsage, src OpenAIUsage) {
if src.ImageInputTokens > 0 {
dst.ImageInputTokens = src.ImageInputTokens
}
if src.ImageCacheReadTokens > 0 {
dst.ImageCacheReadTokens = src.ImageCacheReadTokens
}
if src.OutputTokens > 0 {
dst.OutputTokens = src.OutputTokens
}
@@ -223,6 +223,7 @@ func (s *OpenAICodexUsageSnapshot) Normalize() *NormalizedCodexLimits {
type OpenAIUsage struct {
InputTokens int `json:"input_tokens"`
ImageInputTokens int `json:"image_input_tokens,omitempty"`
ImageCacheReadTokens int `json:"image_cache_read_tokens,omitempty"`
OutputTokens int `json:"output_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"`
CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"`
@@ -185,12 +185,13 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
// Calculate cost
tokens := UsageTokens{
InputTokens: actualInputTokens,
ImageInputTokens: result.Usage.ImageInputTokens,
OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
CacheReadTokens: result.Usage.CacheReadInputTokens,
ImageOutputTokens: result.Usage.ImageOutputTokens,
InputTokens: actualInputTokens,
ImageInputTokens: max(result.Usage.ImageInputTokens-result.Usage.ImageCacheReadTokens, 0),
ImageCacheReadTokens: result.Usage.ImageCacheReadTokens,
OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
CacheReadTokens: result.Usage.CacheReadInputTokens,
ImageOutputTokens: result.Usage.ImageOutputTokens,
}
// Get rate multiplier
@@ -366,6 +367,14 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
)
}
imageSizeBreakdown := cloneImageSizeBreakdown(result.ImageSizeBreakdown)
if result.Usage.ImageCacheReadTokens > 0 {
if imageSizeBreakdown == nil {
imageSizeBreakdown = make(map[string]int)
}
// Keep the image cache split in the existing usage_logs JSONB payload.
imageSizeBreakdown["image_cache_read_tokens"] = result.Usage.ImageCacheReadTokens
}
usageLog := &UsageLog{
UserID: user.ID,
APIKeyID: apiKey.ID,
@@ -393,7 +402,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize),
ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize),
ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource),
ImageSizeBreakdown: result.ImageSizeBreakdown,
ImageSizeBreakdown: imageSizeBreakdown,
NativeCompactionV2: input.NativeCompactionV2,
}
isVideoUsage := isGrokVideoUsageResult(result, billingModels)
@@ -1144,3 +1153,14 @@ func (s *OpenAIGatewayService) UpdateCodexUsageSnapshotFromHeaders(ctx context.C
s.updateCodexUsageSnapshot(ctx, accountID, snapshot)
}
}
func cloneImageSizeBreakdown(input map[string]int) map[string]int {
if len(input) == 0 {
return nil
}
output := make(map[string]int, len(input))
for key, value := range input {
output[key] = value
}
return output
}
+76 -4
View File
@@ -692,7 +692,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
imageCount := parsed.N
var firstTokenMs *int
if parsed.Stream && isEventStreamResponse(resp.Header) {
streamUsage, streamCount, streamSizes, ttft, err := s.handleOpenAIImagesStreamingResponse(resp, c, startTime)
streamUsage, streamCount, streamSizes, ttft, err := s.handleOpenAIImagesStreamingResponse(resp, c, startTime, nil)
if err != nil {
if streamCount > 0 {
return &OpenAIForwardResult{
@@ -934,6 +934,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
resp *http.Response,
c *gin.Context,
startTime time.Time,
direct *OpenAIImagesRequest,
) (OpenAIUsage, int, []string, *int, error) {
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
contentType := strings.TrimSpace(resp.Header.Get("Content-Type"))
@@ -959,13 +960,81 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
seenSSEData := false
fallbackTooLarge := false
var sseData openAISSEDataAccumulator
var streamErr error
finish := func() error {
if direct == nil {
return nil
}
if streamErr != nil {
return streamErr
}
if !seenSSEData || imageCounter.Count() == 0 {
return newOpenAIUpstreamStreamReadError(ErrOpenAIUpstreamStreamTruncated)
}
return nil
}
processSSEData := func(dataBytes []byte) {
if streamErr != nil {
return
}
seenSSEData = true
fallbackBody.Reset()
fallbackBytes = 0
if direct != nil && strings.HasSuffix(gjson.GetBytes(dataBytes, "type").String(), ".completed") {
if size := detectOpenAIImageResultSize(gjson.GetBytes(dataBytes, "b64_json").String()); size != "" {
dataBytes, _ = sjson.SetBytes(dataBytes, "size", size)
}
}
mergeOpenAIUsage(&usage, dataBytes)
imageCounter.AddSSEData(dataBytes)
if direct == nil || string(dataBytes) == "[DONE]" {
return
}
if directUsage, ok := codexDirectImagesUsage(dataBytes); ok {
mergeOpenAIUsageNonZero(&usage, directUsage)
}
if observer := upstreamResponseModelObserverFromContext(c); observer != nil {
observer.Observe(gjson.GetBytes(dataBytes, "model").String(), strings.HasSuffix(gjson.GetBytes(dataBytes, "type").String(), ".completed"))
}
if upstreamErr := openAIImagesUpstreamErrorFromSSEPayload(dataBytes); upstreamErr != nil {
streamErr = upstreamErr
if IsOpenAIImagesRetryableUpstreamError(upstreamErr) && imageCounter.Count() == 0 {
return
}
}
if !gjson.ValidBytes(dataBytes) {
streamErr = newOpenAIUpstreamStreamReadError(fmt.Errorf("invalid image stream JSON"))
return
}
eventType := gjson.GetBytes(dataBytes, "type").String()
if direct != nil && strings.TrimSpace(direct.Model) != "" {
dataBytes, _ = sjson.SetBytes(dataBytes, "model", strings.TrimSpace(direct.Model))
}
// 原生编辑事件可能仍使用 image_generation 前缀;对外维持既有编辑事件名。
if strings.HasPrefix(eventType, "image_generation.") && direct.IsEdits() {
eventType = strings.Replace(eventType, "image_generation.", "image_edit.", 1)
dataBytes, _ = sjson.SetBytes(dataBytes, "type", eventType)
}
if direct.ResponseFormat == "url" {
b64 := gjson.GetBytes(dataBytes, "b64_json").String()
format := gjson.GetBytes(dataBytes, "output_format").String()
if format == "" {
format = direct.OutputFormat
}
dataBytes = codexDirectImageURL(dataBytes, "", format)
// 既有流式契约在 url 模式同时保留 b64_json。
if b64 != "" {
dataBytes, _ = sjson.SetBytes(dataBytes, "b64_json", b64)
}
}
if !clientDisconnected {
if err := s.writeOpenAIImagesStreamEvent(c, flusher, eventType, dataBytes); err != nil {
clientDisconnected = true
} else {
lastDownstreamWriteAt = time.Now()
}
}
}
flushSSEEvent := func() {
@@ -980,7 +1049,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
ms := int(time.Since(startTime).Milliseconds())
firstTokenMs = &ms
}
if !clientDisconnected {
if !clientDisconnected && direct == nil {
if _, writeErr := c.Writer.Write(line); writeErr != nil {
clientDisconnected = true
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images stream client disconnected, continue draining upstream for billing")
@@ -1007,6 +1076,9 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
}
finalizeFallbackBody := func() {
if direct != nil {
return
}
if seenSSEData || fallbackBody.Len() == 0 {
return
}
@@ -1035,7 +1107,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
}
flushSSEEvent()
finalizeFallbackBody()
return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, nil
return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, finish()
}
type readEvent struct {
@@ -1102,7 +1174,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
if !ok {
flushSSEEvent()
finalizeFallbackBody()
return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, nil
return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, finish()
}
if ev.err != nil {
flushSSEEvent()
@@ -74,7 +74,7 @@ type openAIOAuthImageActualSizeTestRun struct {
func runOpenAIOAuthImageActualSizeTest(t *testing.T, stream bool) openAIOAuthImageActualSizeTestRun {
t.Helper()
gin.SetMode(gin.TestMode)
body := []byte(fmt.Sprintf(`{"model":"gpt-image-2","prompt":"draw a test chart","size":"3840x2160","quality":"low","output_format":"png","stream":%t}`, stream))
body := []byte(fmt.Sprintf(`{"model":"gpt-image-1","prompt":"draw a test chart","size":"3840x2160","quality":"low","output_format":"png","stream":%t}`, stream))
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
@@ -84,8 +84,8 @@ func runOpenAIOAuthImageActualSizeTest(t *testing.T, stream bool) openAIOAuthIma
encoded := encodeOpenAIImageTestPNG(t, 1672, 941)
upstreamBody := fmt.Sprintf(
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000000,\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-2\",\"size\":\"auto\",\"quality\":\"auto\",\"output_format\":\"png\"}]}}\n\n"+
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000000,\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-2\",\"size\":\"auto\",\"quality\":\"auto\",\"output_format\":\"png\"}],\"output\":[{\"id\":\"ig_actual_size\",\"type\":\"image_generation_call\",\"result\":%q}]}}\n\n"+
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000000,\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-1\",\"size\":\"auto\",\"quality\":\"auto\",\"output_format\":\"png\"}]}}\n\n"+
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000000,\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-1\",\"size\":\"auto\",\"quality\":\"auto\",\"output_format\":\"png\"}],\"output\":[{\"id\":\"ig_actual_size\",\"type\":\"image_generation_call\",\"result\":%q}]}}\n\n"+
"data: [DONE]\n\n",
encoded,
)
@@ -0,0 +1,261 @@
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
type openAIImagesForceResponsesContextKey struct{}
func withOpenAIImagesForceResponses(ctx context.Context) context.Context {
return context.WithValue(ctx, openAIImagesForceResponsesContextKey{}, true)
}
func isOpenAIImagesForceResponses(ctx context.Context) bool {
if ctx == nil {
return false
}
forced, _ := ctx.Value(openAIImagesForceResponsesContextKey{}).(bool)
return forced
}
// 显式列出已接入的模型,不把未来模型或未知快照自动送到直调端点。
func usesCodexDirectImages(model string) bool {
switch strings.TrimSpace(model) {
case "gpt-image-1.5", "gpt-image-2",
"gpt-image-2.5-flare", "gpt-image-2.5-sunburst",
"gpt-image-2.5-flare-2026-09-08", "gpt-image-2.5-sunburst-2026-09-08":
return true
default:
return false
}
}
// 正式转发与后台测试共用同一份端点选择和请求构造。
func buildOpenAIImagesOAuthPayload(parsed *OpenAIImagesRequest, model string) ([]byte, string, error) {
if parsed == nil {
return nil, "", fmt.Errorf("parsed images request is required")
}
if !usesCodexDirectImages(model) {
body, err := buildOpenAIImagesResponsesRequest(parsed, model)
return body, chatgptCodexURL, err
}
if strings.TrimSpace(parsed.Prompt) == "" {
return nil, "", fmt.Errorf("prompt is required")
}
// 只把已解析并校验的图片字段发送给上游,避免客户端注入任意 JSON 字段。
payload := make(map[string]any, 16)
payload["model"] = model
prompt := parsed.Prompt
if !parsed.Multipart && gjson.ValidBytes(parsed.Body) {
if rawPrompt := gjson.GetBytes(parsed.Body, "prompt").String(); rawPrompt != "" {
prompt = rawPrompt
}
}
payload["prompt"] = prompt
for _, field := range []struct {
key string
value string
}{
{"size", parsed.Size}, {"quality", parsed.Quality},
{"background", parsed.Background}, {"output_format", parsed.OutputFormat},
{"moderation", parsed.Moderation}, {"input_fidelity", parsed.InputFidelity},
{"style", parsed.Style},
} {
if value := strings.TrimSpace(field.value); value != "" {
payload[field.key] = value
}
}
if parsed.N > 1 {
payload["n"] = parsed.N
}
if parsed.OutputCompression != nil {
payload["output_compression"] = *parsed.OutputCompression
}
if parsed.PartialImages != nil {
payload["partial_images"] = *parsed.PartialImages
}
if parsed.Stream {
payload["stream"] = true
}
endpoint := "/images/generations"
if parsed.IsEdits() {
endpoint = "/images/edits"
images := make([]map[string]string, 0, len(parsed.InputImageURLs)+len(parsed.Uploads))
for _, imageURL := range parsed.InputImageURLs {
if imageURL = strings.TrimSpace(imageURL); imageURL != "" {
images = append(images, map[string]string{"image_url": imageURL})
}
}
for _, upload := range parsed.Uploads {
url, err := openAIImageUploadToDataURL(upload)
if err != nil {
return nil, "", err
}
images = append(images, map[string]string{"image_url": url})
}
if len(images) == 0 {
return nil, "", fmt.Errorf("image input is required")
}
payload["images"] = images
mask := strings.TrimSpace(parsed.MaskImageURL)
if parsed.MaskUpload != nil {
var err error
mask, err = openAIImageUploadToDataURL(*parsed.MaskUpload)
if err != nil {
return nil, "", err
}
}
if mask != "" {
payload["mask"] = map[string]string{"image_url": mask}
}
}
body, err := json.Marshal(payload)
if err != nil {
return nil, "", fmt.Errorf("marshal Codex Images request: %w", err)
}
return body, strings.TrimSuffix(chatgptCodexURL, "/responses") + endpoint, nil
}
// 原生 JSON 响应与后台图片预览使用同一份校验,防止空 data 被计为成功。
func parseCodexDirectImagesResponse(body []byte) ([]openAIResponsesImageResult, error) {
if !gjson.ValidBytes(body) {
return nil, fmt.Errorf("invalid Images API JSON response")
}
if upstreamErr := openAIImagesUpstreamErrorFromSSEPayload(body); upstreamErr != nil {
return nil, upstreamErr
}
root := gjson.ParseBytes(body)
if upstreamErr := openAIImagesUpstreamErrorFromGJSON(root.Get("error"), ""); upstreamErr != nil {
return nil, upstreamErr
}
var results []openAIResponsesImageResult
for _, item := range root.Get("data").Array() {
result := strings.TrimSpace(item.Get("b64_json").String())
if result == "" {
continue
}
meta := func(key string) string {
if value := item.Get(key).String(); value != "" {
return value
}
return root.Get(key).String()
}
results = append(results, openAIResponsesImageResult{
Result: result, RevisedPrompt: item.Get("revised_prompt").String(),
OutputFormat: meta("output_format"), Size: meta("size"),
Quality: meta("quality"), Background: meta("background"), Model: meta("model"),
})
}
if len(results) == 0 {
return nil, &OpenAIImagesUpstreamError{StatusCode: 502, ErrorType: "upstream_error", Message: "Images API returned no image output"}
}
reconcileOpenAIResponsesImageResultSizes(results, nil)
return results, nil
}
func codexDirectImageURL(body []byte, path, outputFormat string) []byte {
if result := gjson.GetBytes(body, path+"b64_json").String(); result != "" {
body, _ = sjson.SetBytes(body, path+"url", "data:"+openAIImageOutputMIMEType(outputFormat)+";base64,"+result)
body, _ = sjson.DeleteBytes(body, path+"b64_json")
}
return body
}
func isOpenAIImagesMainModelError(status int, body []byte) bool {
if !isOpenAICodexPlanGatedModelError(status, body) {
return false
}
message := extractUpstreamErrorMessage(body)
model := openAIImagesResponsesMainModelValue()
return strings.Contains(message, "'"+model+"'") ||
strings.Contains(message, `"`+model+`"`)
}
// Images 端点只输出图片;未提供输出分类时,output_tokens 全部是图片 token。
// 缓存图片数量只采信明确明细,不根据总缓存量猜测图文占比。
func codexDirectImagesUsage(body []byte) (OpenAIUsage, bool) {
value := gjson.GetBytes(body, "usage")
usage, ok := openAIUsageFromGJSON(value)
if !ok {
return usage, false
}
if !value.Get("output_tokens_details.image_tokens").Exists() {
usage.ImageOutputTokens = usage.OutputTokens
}
cached := value.Get("input_tokens_details.cached_tokens_details")
if !value.Get("input_tokens_details.cached_tokens").Exists() && cached.IsObject() {
imageTokens, _ := boundedJSONNonNegativeInt(cached.Get("image_tokens"))
textTokens, _ := boundedJSONNonNegativeInt(cached.Get("text_tokens"))
usage.CacheReadInputTokens = min(imageTokens, max(usage.InputTokens, 0))
usage.CacheReadInputTokens += min(textTokens, max(usage.InputTokens-usage.CacheReadInputTokens, 0))
}
imageCached, _ := boundedJSONNonNegativeInt(cached.Get("image_tokens"))
usage.ImageCacheReadTokens = min(imageCached, max(usage.ImageInputTokens, 0), max(usage.CacheReadInputTokens, 0))
return usage, true
}
func (s *OpenAIGatewayService) handleCodexDirectImagesNonStreamingResponse(resp *http.Response, c *gin.Context, parsed *OpenAIImagesRequest) (OpenAIUsage, int, []string, error) {
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
if err != nil {
if shouldClassifyOpenAIUpstreamStreamReadError(err) {
err = newOpenAIUpstreamStreamReadError(err)
}
return OpenAIUsage{}, 0, nil, err
}
results, err := parseCodexDirectImagesResponse(body)
if err != nil {
return OpenAIUsage{}, 0, nil, err
}
usage, _ := codexDirectImagesUsage(body)
if observer := upstreamResponseModelObserverFromContext(c); observer != nil {
observer.Observe(gjson.GetBytes(body, "model").String(), true)
for _, result := range results {
observer.Observe(result.Model, true)
}
}
clientModel := strings.TrimSpace(parsed.Model)
if clientModel != "" {
for i := range gjson.GetBytes(body, "data").Array() {
body, _ = sjson.SetBytes(body, fmt.Sprintf("data.%d.model", i), clientModel)
}
}
for i, item := range gjson.GetBytes(body, "data").Array() {
if actualSize := detectOpenAIImageResultSize(item.Get("b64_json").String()); actualSize != "" {
body, _ = sjson.SetBytes(body, fmt.Sprintf("data.%d.size", i), actualSize)
if i == 0 {
body, _ = sjson.SetBytes(body, "size", actualSize)
}
}
}
if parsed.ResponseFormat == "url" {
for i, item := range gjson.GetBytes(body, "data").Array() {
format := item.Get("output_format").String()
if format == "" {
format = gjson.GetBytes(body, "output_format").String()
}
if format == "" {
format = parsed.OutputFormat
}
body = codexDirectImageURL(body, fmt.Sprintf("data.%d.", i), format)
}
}
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
contentType := "application/json"
if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled {
if upstreamType := strings.TrimSpace(resp.Header.Get("Content-Type")); upstreamType != "" {
contentType = upstreamType
}
}
c.Data(resp.StatusCode, contentType, body)
return usage, len(results), openAIResponsesImageResultSizes(results), nil
}
@@ -0,0 +1,123 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"os"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestCodexDirectImagesMultipartEdit(t *testing.T) {
var body bytes.Buffer
w := multipart.NewWriter(&body)
for key, value := range map[string]string{"model": "gpt-image-2.5-sunburst", "prompt": "编辑", "quality": "xhigh", "n": "2", "output_format": "webp", "output_compression": "75", "partial_images": "2", "input_fidelity": "high"} {
require.NoError(t, w.WriteField(key, value))
}
for _, field := range []string{"image[]", "image[]", "mask"} {
header := textproto.MIMEHeader{}
header.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename="image.png"`, field))
header.Set("Content-Type", "image/png")
part, err := w.CreatePart(header)
require.NoError(t, err)
_, err = part.Write([]byte("png-content"))
require.NoError(t, err)
}
require.NoError(t, w.Close())
c, _ := newOpenAIImagesTestContext(t, body.Bytes())
c.Request.URL.Path = "/v1/images/edits"
c.Request.Header.Set("Content-Type", w.FormDataContentType())
svc := newOpenAIImagesTestService(nil)
parsed, err := svc.ParseOpenAIImagesRequest(c, body.Bytes())
require.NoError(t, err)
upstreamBody, target, err := buildOpenAIImagesOAuthPayload(parsed, parsed.Model)
require.NoError(t, err)
require.Equal(t, "https://chatgpt.com/backend-api/codex/images/edits", target)
require.Len(t, gjson.GetBytes(upstreamBody, "images").Array(), 2)
require.Equal(t, "data:image/png;base64,cG5nLWNvbnRlbnQ=", gjson.GetBytes(upstreamBody, "mask.image_url").String())
require.Equal(t, "xhigh", gjson.GetBytes(upstreamBody, "quality").String())
require.Equal(t, "high", gjson.GetBytes(upstreamBody, "input_fidelity").String())
require.EqualValues(t, 2, gjson.GetBytes(upstreamBody, "n").Int())
require.EqualValues(t, 75, gjson.GetBytes(upstreamBody, "output_compression").Int())
require.EqualValues(t, 2, gjson.GetBytes(upstreamBody, "partial_images").Int())
require.False(t, gjson.GetBytes(upstreamBody, "tools").Exists())
}
func TestCodexDirectImagesPricingAndUsage(t *testing.T) {
prices := &PricingService{pricingData: map[string]*LiteLLMModelPricing{"gpt-image-2": {InputCostPerToken: 1}}}
for _, model := range []string{"gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2.5-flare-2026-09-08", "gpt-image-2.5-sunburst-2026-09-08"} {
price := prices.matchOpenAIModel(model)
require.Equal(t, 5e-6, price.InputCostPerToken)
require.Equal(t, 8e-6, price.InputCostPerImageToken)
require.Equal(t, 30e-6, price.OutputCostPerImageToken)
require.Equal(t, 2e-6, price.CacheReadInputImageTokenCost)
}
body, err := os.ReadFile("../../resources/model-pricing/model_prices_and_context_window.json")
require.NoError(t, err)
var catalog map[string]LiteLLMModelPricing
require.NoError(t, json.Unmarshal(body, &catalog))
require.Equal(t, 2e-6, catalog["gpt-image-2.5-flare"].CacheReadInputImageTokenCost)
usage, ok := codexDirectImagesUsage([]byte(`{"usage":{"input_tokens":100,"input_tokens_details":{"text_tokens":20,"image_tokens":80,"cached_tokens":50,"cached_tokens_details":{"text_tokens":10,"image_tokens":40}},"output_tokens":200}}`))
require.True(t, ok)
require.Equal(t, 40, usage.ImageCacheReadTokens)
require.Equal(t, 200, usage.ImageOutputTokens)
tokens := UsageTokens{InputTokens: usage.InputTokens - usage.CacheReadInputTokens, ImageInputTokens: usage.ImageInputTokens - usage.ImageCacheReadTokens, CacheReadTokens: usage.CacheReadInputTokens, ImageCacheReadTokens: usage.ImageCacheReadTokens, OutputTokens: usage.OutputTokens, ImageOutputTokens: usage.ImageOutputTokens}
price := &ModelPricing{InputPricePerToken: 5e-6, ImageInputPricePerToken: 8e-6, CacheReadPricePerToken: 1.25e-6, ImageCacheReadPricePerToken: 2e-6, ImageOutputPricePerToken: 30e-6}
cost := (&BillingService{}).computeTokenBreakdown(price, tokens, 1, "", false)
require.InDelta(t, 10*5e-6, cost.InputCost, 1e-12)
require.InDelta(t, 40*8e-6, cost.ImageInputCost, 1e-12)
require.InDelta(t, 10*1.25e-6+40*2e-6, cost.CacheReadCost, 1e-12)
require.InDelta(t, 200*30e-6, cost.ImageOutputCost, 1e-12)
unknown, _ := codexDirectImagesUsage([]byte(`{"usage":{"input_tokens":100,"input_tokens_details":{"image_tokens":80,"cached_tokens":50},"output_tokens":200}}`))
require.Zero(t, unknown.ImageCacheReadTokens, "不能根据总缓存量推测图片缓存占比")
}
func TestCodexImagesLunaErrorDoesNotCoolImageAccount(t *testing.T) {
for _, stream := range []bool{false, true} {
t.Run(fmt.Sprint(stream), func(t *testing.T) {
body := []byte(fmt.Sprintf(`{"model":"gpt-image-1","prompt":"draw","stream":%t}`, stream))
c, rec := newOpenAIImagesTestContext(t, body)
upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
require.Equal(t, "/backend-api/codex/responses", req.URL.Path)
return &http.Response{StatusCode: 400, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"The 'gpt-5.6-luna' model is not supported when using Codex with a ChatGPT account."}}`))}, nil
}}
svc := newOpenAIImagesTestService(upstream)
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
require.NoError(t, err)
result, err := svc.ForwardImages(context.Background(), c, directImagesTestAccount(), body, parsed, "")
require.Nil(t, result)
var upstreamErr *OpenAIImagesUpstreamError
require.ErrorAs(t, err, &upstreamErr)
require.Contains(t, rec.Body.String(), "gpt-5.6-luna")
var failover *UpstreamFailoverError
require.NotErrorAs(t, err, &failover)
})
}
}
func TestCodexDirectImagesShadowCredentials(t *testing.T) {
parent := directImagesTestAccount()
parent.Status = StatusActive
shadow := &Account{ID: 99, ParentAccountID: &parent.ID, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
body := []byte(`{"model":"gpt-image-2","prompt":"draw"}`)
c, _ := newOpenAIImagesTestContext(t, body)
upstream := &httpUpstreamRecorder{resp: openAIImagesJSONResponse()}
svc := newOpenAIImagesTestService(upstream)
svc.accountRepo = newStubCredRepo(parent)
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
require.NoError(t, err)
_, err = svc.ForwardImages(context.Background(), c, shadow, body, parsed, "")
require.NoError(t, err)
require.Equal(t, "Bearer test-token", upstream.lastReq.Header.Get("Authorization"))
require.Equal(t, "test-account", upstream.lastReq.Header.Get("Chatgpt-Account-Id"))
require.Equal(t, "/backend-api/codex/images/generations", upstream.lastReq.URL.Path)
}
@@ -0,0 +1,231 @@
package service
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func directImagesTestAccount() *Account {
return &Account{ID: 35, Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Credentials: map[string]any{"access_token": "test-token", "chatgpt_account_id": "test-account"}}
}
func TestCodexDirectImagesRouting(t *testing.T) {
for _, model := range []string{"gpt-image-1.5", "gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2.5-flare-2026-09-08", "gpt-image-2.5-sunburst-2026-09-08"} {
t.Run(model, func(t *testing.T) {
body := []byte(fmt.Sprintf(`{"model":%q,"prompt":" 原样保留 prompt ","quality":"max","size":"auto","response_format":"url","extra":{"preserve":true}}`, model))
c, rec := newOpenAIImagesTestContext(t, body)
upstream := &httpUpstreamRecorder{resp: openAIImagesJSONResponse()}
svc := newOpenAIImagesTestService(upstream)
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
cancel()
result, err := svc.ForwardImages(ctx, c, directImagesTestAccount(), body, parsed, "")
require.NoError(t, err)
require.Equal(t, 1, result.ImageCount)
require.Equal(t, 20, result.Usage.ImageOutputTokens)
require.Equal(t, model, result.UpstreamModel)
require.Equal(t, "/backend-api/codex/images/generations", result.UpstreamEndpoint)
require.Equal(t, "https://chatgpt.com/backend-api/codex/images/generations", upstream.lastReq.URL.String())
require.NoError(t, upstream.lastReq.Context().Err())
require.Equal(t, "Bearer test-token", upstream.lastReq.Header.Get("Authorization"))
require.Equal(t, "application/json", upstream.lastReq.Header.Get("Accept"))
require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta"))
require.NotEmpty(t, upstream.lastReq.Header.Get("Originator"))
require.NotEmpty(t, upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, model, gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, " 原样保留 prompt ", gjson.GetBytes(upstream.lastBody, "prompt").String())
require.False(t, gjson.GetBytes(upstream.lastBody, "extra").Exists())
for _, key := range []string{"tools", "reasoning", "instructions", "response_format", "stream"} {
require.False(t, gjson.GetBytes(upstream.lastBody, key).Exists(), key)
}
require.Equal(t, "data:image/png;base64,aGVsbG8=", gjson.GetBytes(rec.Body.Bytes(), "data.0.url").String())
})
}
}
func TestCodexDirectImagesMappingBeforeRouting(t *testing.T) {
for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
t.Run(accountType, func(t *testing.T) {
body := []byte(`{"model":"gpt-image-1","prompt":"draw"}`)
c, _ := newOpenAIImagesTestContext(t, body)
upstream := &httpUpstreamRecorder{resp: openAIImagesJSONResponse()}
svc := newOpenAIImagesTestService(upstream)
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
require.NoError(t, err)
account := directImagesTestAccount()
account.Type = accountType
account.Credentials["model_mapping"] = map[string]any{"gpt-image-2": "gpt-image-2.5-flare"}
result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "gpt-image-2")
require.NoError(t, err)
require.Equal(t, "gpt-image-2.5-flare", result.UpstreamModel)
require.Equal(t, "gpt-image-2", result.Model)
require.Equal(t, "gpt-image-2.5-flare", gjson.GetBytes(upstream.lastBody, "model").String())
})
}
for _, model := range []string{"gpt-image-1", "gpt-image-future"} {
body, target, err := buildOpenAIImagesOAuthPayload(&OpenAIImagesRequest{Prompt: "draw"}, model)
require.NoError(t, err)
require.Equal(t, chatgptCodexURL, target)
require.Equal(t, "gpt-5.6-luna", gjson.GetBytes(body, "model").String())
require.Equal(t, model, gjson.GetBytes(body, "tools.0.model").String())
}
}
func TestCodexDirectImagesHTTPErrorFallbacksOnlyWhenEndpointUnavailable(t *testing.T) {
for _, status := range []int{400, 401, 403, 429, 500, 502, 503, 404, 405} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
calls := 0
upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
calls++
if calls == 1 {
require.Equal(t, "/backend-api/codex/images/generations", req.URL.Path)
return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"error":{"type":"server_error","message":"image request rejected"}}`))}, nil
}
require.Contains(t, req.URL.Path, "/backend-api/codex/responses")
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"text/event-stream"}}, Body: io.NopCloser(strings.NewReader("data: {\"type\":\"response.completed\",\"response\":{\"output\":[{\"type\":\"image_generation_call\",\"result\":\"aGVsbG8=\"}]}}\n\n"))}, nil
}}
body := []byte(`{"model":"gpt-image-2.5-sunburst","prompt":"draw"}`)
c, _ := newOpenAIImagesTestContext(t, body)
svc := newOpenAIImagesTestService(upstream)
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
require.NoError(t, err)
result, err := svc.ForwardImages(context.Background(), c, directImagesTestAccount(), body, parsed, "")
if status == http.StatusNotFound || status == http.StatusMethodNotAllowed {
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 2, calls)
} else {
require.Error(t, err)
require.Equal(t, 1, calls)
}
})
}
}
func TestCodexDirectImagesStreamRejectsPlainJSON(t *testing.T) {
body := []byte(`{"model":"gpt-image-2","prompt":"draw","stream":true}`)
c, _ := newOpenAIImagesTestContext(t, body)
svc := newOpenAIImagesTestService(&httpUpstreamRecorder{resp: openAIImagesJSONResponse()})
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
require.NoError(t, err)
result, err := svc.ForwardImages(context.Background(), c, directImagesTestAccount(), body, parsed, "")
require.Error(t, err, "未收到 SSE 完成事件,不能把未转发的 JSON 当作成功")
require.Nil(t, result)
}
func TestCodexDirectImagesMultipleOutputs(t *testing.T) {
for _, stream := range []bool{false, true} {
t.Run(fmt.Sprint(stream), func(t *testing.T) {
body := []byte(fmt.Sprintf(`{"model":"gpt-image-2.5-flare","prompt":"draw","n":2,"stream":%t}`, stream))
c, _ := newOpenAIImagesTestContext(t, body)
response := `{"data":[{"b64_json":"AA=="},{"b64_json":"AQ=="}],"usage":{"input_tokens":10,"output_tokens":40}}`
if stream {
response = "data: {\"type\":\"image_generation.completed\",\"b64_json\":\"AA==\"}\n\ndata: {\"type\":\"image_generation.completed\",\"b64_json\":\"AQ==\",\"usage\":{\"input_tokens\":10,\"output_tokens\":40}}\n\n"
}
upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(response))}}
svc := newOpenAIImagesTestService(upstream)
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
require.NoError(t, err)
result, err := svc.ForwardImages(context.Background(), c, directImagesTestAccount(), body, parsed, "")
require.NoError(t, err)
require.Equal(t, 2, result.ImageCount)
require.Equal(t, 40, result.Usage.ImageOutputTokens)
})
}
}
func TestCodexDirectImagesStreaming(t *testing.T) {
for _, test := range []struct {
name, events string
wantCount int
wantError, disconnect bool
}{
{"completed", "complete", 1, false, false},
{"duplicate", "complete,complete", 1, false, false},
{"disconnect", "partial,complete", 1, false, true},
{"truncated", "partial", 0, true, false},
{"partial_requested_multiple", "complete", 1, false, false},
{"error", "error", 0, true, false},
{"partial_success", "complete,error", 1, true, false},
} {
t.Run(test.name, func(t *testing.T) {
body := []byte(`{"model":"gpt-image-2","prompt":"edit","images":[{"image_url":"data:image/png;base64,AA=="}],"stream":true,"response_format":"url"}`)
if test.name == "partial_requested_multiple" {
body = []byte(`{"model":"gpt-image-2","prompt":"edit","n":2,"images":[{"image_url":"data:image/png;base64,AA=="}],"stream":true,"response_format":"url"}`)
}
c, rec := newOpenAIImagesTestContext(t, body)
c.Request.URL.Path = "/v1/images/edits"
if test.disconnect {
c.Writer = &failingOpenAIImageWriter{ResponseWriter: c.Writer, failAfter: 1}
}
frames := map[string]string{
"partial": `{"type":"image_generation.partial_image","b64_json":"AA==","partial_image_index":0}`,
"complete": `{"type":"image_generation.completed","model":"gpt-image-2-codex","size":"1024x1024","b64_json":"aGVsbG8=","output_format":"webp","usage":{"input_tokens":10,"output_tokens":20}}`,
"error": `{"type":"error","error":{"type":"server_error","message":"overloaded"}}`,
}
var stream strings.Builder
for _, event := range strings.Split(test.events, ",") {
fmt.Fprintf(&stream, "data: %s\n\n", frames[event])
}
upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(stream.String()))}}
svc := newOpenAIImagesTestService(upstream)
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
require.NoError(t, err)
result, err := svc.ForwardImages(context.Background(), c, directImagesTestAccount(), body, parsed, "")
if test.wantError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
if test.wantCount > 0 {
require.NotNil(t, result)
require.Equal(t, test.wantCount, result.ImageCount)
require.Equal(t, 20, result.Usage.ImageOutputTokens)
if !test.wantError {
require.Equal(t, "gpt-image-2-codex", result.UpstreamResponseModel)
}
}
require.Equal(t, "/backend-api/codex/images/edits", upstream.lastReq.URL.Path)
require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept"))
if !test.disconnect && test.wantCount > 0 {
require.Contains(t, rec.Body.String(), "event: image_edit.completed")
require.Contains(t, rec.Body.String(), "data:image/webp;base64,aGVsbG8=")
}
})
}
}
func TestCodexDirectImagesEmptyResponseFails(t *testing.T) {
for _, body := range []string{`{"data":[]}`, `{"data":[{"b64_json":""}]}`, `{}`, `{"error":{"message":"overloaded","type":"server_error"}}`, `not json`} {
_, err := parseCodexDirectImagesResponse([]byte(body))
require.Error(t, err)
}
}
func TestCodexDirectImagesAccountTestAndWhitelist(t *testing.T) {
body := []byte(`{"model":"gpt-image-2.5-sunburst","prompt":"draw"}`)
c, rec := newOpenAIImagesTestContext(t, body)
upstream := &httpUpstreamRecorder{resp: openAIImagesJSONResponse()}
svc := &AccountTestService{httpUpstream: upstream}
require.NoError(t, svc.testOpenAIImageOAuth(c, context.Background(), directImagesTestAccount(), "gpt-image-2.5-sunburst", "draw"))
require.Equal(t, "/backend-api/codex/images/generations", upstream.lastReq.URL.Path)
require.Contains(t, rec.Body.String(), `"success":true`)
newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"gpt-5.6-luna"}]}`)
svc.openaiGatewayService = &OpenAIGatewayService{}
account := newCodexModelsTestAccount()
account.Credentials["model_mapping"] = map[string]any{"gpt-image-2.5-flare": "gpt-image-2.5-flare"}
models, err := svc.FetchOpenAIAccountModels(context.Background(), account)
require.NoError(t, err)
for _, model := range models {
require.NotEqual(t, "gpt-image-2.5-sunburst", model.ID)
}
}
@@ -180,7 +180,7 @@ func TestOpenAIImagesJSONKeepaliveWriter_NilGuards(t *testing.T) {
// 可重试上游错误必须仍转换为 UpstreamFailoverError(而非裸错误吞掉换号)。
func TestOpenAIImagesJSONKeepalive_HeartbeatBeforeForwardStillFailsOver(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`)
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","response_format":"b64_json"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
@@ -9,6 +9,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync/atomic"
"time"
@@ -978,17 +979,12 @@ func (s *OpenAIGatewayService) handleOpenAIImagesErrorResponse(
return nil, upErr
}
// A retired/configured Responses driver is not an image-model quota failure.
// Surface the actionable upstream error instead of cooling every image account
// and eventually hiding the configuration problem behind a generic 503.
if account.IsOpenAIOAuthLike() &&
isOpenAICodexPlanGatedModelError(resp.StatusCode, body) &&
strings.Contains(extractUpstreamErrorMessage(body), "'"+openAIImagesResponsesMainModelValue()+"'") {
// 主控不可用不代表图片模型配额耗尽,直接透传,避免误冷却整个图片账号池。
if account.IsOpenAIOAuthLike() && isOpenAIImagesMainModelError(resp.StatusCode, body) {
upErr := openAIImagesUpstreamErrorFromHTTP(resp.StatusCode, resp.Header, body)
writeOpenAIImagesUpstreamErrorResponse(c, upErr)
return nil, upErr
}
// Track rate limits / decide whether to disable the account (secondary failover).
var modelForCooldown string
if len(requestedModel) > 0 {
@@ -1194,12 +1190,9 @@ func openAIImagesToolUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) {
return OpenAIUsage{}, false
}
imageInputTokens, _ := boundedJSONNonNegativeInt(value.Get("input_tokens_details.image_tokens"))
if imageInputTokens > inputTokens {
imageInputTokens = inputTokens
}
return OpenAIUsage{
InputTokens: inputTokens,
ImageInputTokens: imageInputTokens,
ImageInputTokens: min(imageInputTokens, inputTokens),
OutputTokens: outputTokens,
ImageOutputTokens: imageOutputTokens,
}, true
@@ -1806,6 +1799,13 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
if err := validateOpenAIImagesModel(requestModel); err != nil {
return nil, err
}
upstreamModel := account.GetMappedModel(requestModel)
if err := validateOpenAIImagesModel(upstreamModel); err != nil {
return nil, err
}
direct := usesCodexDirectImages(upstreamModel) && !isOpenAIImagesForceResponses(ctx)
beginUpstreamResponseModelObservation(c)
SetOpsUpstreamModel(c, upstreamModel)
logger.LegacyPrintf(
"service.openai_gateway",
"[OpenAI] Images request routing request_model=%s endpoint=%s account_type=%s uploads=%d",
@@ -1822,7 +1822,14 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
return nil, err
}
responsesBody, err := buildOpenAIImagesResponsesRequest(parsed, requestModel)
var responsesBody []byte
var targetURL string
if direct {
responsesBody, targetURL, err = buildOpenAIImagesOAuthPayload(parsed, upstreamModel)
} else {
responsesBody, err = buildOpenAIImagesResponsesRequest(parsed, upstreamModel)
targetURL = chatgptCodexURL
}
if err != nil {
return nil, err
}
@@ -1831,9 +1838,21 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
if err != nil {
return nil, err
}
// 复用 Codex 认证、影子账号及指纹头;仅切换已构造请求的端点和响应协议。
upstreamReq.URL, err = url.Parse(targetURL)
if err != nil {
return nil, err
}
upstreamReq.Header.Set("Content-Type", "application/json")
upstreamReq.Header.Set("Accept", "text/event-stream")
upstreamReq.Header.Set("OpenAI-Beta", "responses=experimental")
if direct {
upstreamReq.Header.Del("OpenAI-Beta")
if !parsed.Stream {
upstreamReq.Header.Set("Accept", "application/json")
}
} else {
upstreamReq.Header.Set("OpenAI-Beta", "responses=experimental")
}
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
@@ -1862,6 +1881,9 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
respBody := s.readUpstreamErrorBody(resp)
_ = resp.Body.Close()
respBody = s.redactAgentIdentitySensitiveBody(upstreamCtx, account, respBody)
if direct && (resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed) {
return s.forwardOpenAIImagesOAuth(withOpenAIImagesForceResponses(ctx), c, account, parsed, channelMappedModel)
}
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 {
@@ -1870,6 +1892,9 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
return s.forwardOpenAIImagesOAuth(markAgentIdentityTaskRecoveryTried(ctx), c, account, parsed, channelMappedModel)
}
resp.Body = io.NopCloser(bytes.NewReader(respBody))
if !direct && isOpenAIImagesMainModelError(resp.StatusCode, respBody) {
return s.handleOpenAIImagesErrorResponse(upstreamCtx, resp, c, account, upstreamModel)
}
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMsg, respBody) {
@@ -1885,7 +1910,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
Kind: "failover",
Message: upstreamMsg,
})
shouldDisable := s.handleFailoverSideEffects(upstreamCtx, resp, account, respBody, requestModel)
shouldDisable := s.handleFailoverSideEffects(upstreamCtx, resp, account, respBody, upstreamModel)
return nil, s.newOpenAIAccountFailoverError(
account,
resp.StatusCode,
@@ -1896,7 +1921,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
!shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
)
}
return s.handleOpenAIImagesErrorResponse(upstreamCtx, resp, c, account, requestModel)
return s.handleOpenAIImagesErrorResponse(upstreamCtx, resp, c, account, upstreamModel)
}
defer func() { _ = resp.Body.Close() }()
@@ -1910,30 +1935,37 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
// 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 direct {
usage, imageCount, imageOutputSizes, firstTokenMs, err = s.handleOpenAIImagesStreamingResponse(resp, c, startTime, parsed)
} else {
usage, imageCount, imageOutputSizes, firstTokenMs, err = s.handleOpenAIImagesOAuthStreamingResponse(resp, c, startTime, parsed.ResponseFormat, openAIImagesStreamPrefix(parsed), upstreamModel)
}
if err != nil {
if imageCount > 0 {
return &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"),
UpstreamHeaders: resp.Header,
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,
RequestID: resp.Header.Get("x-request-id"),
UpstreamHeaders: resp.Header,
Usage: usage,
Model: requestModel,
UpstreamModel: upstreamModel,
UpstreamEndpoint: upstreamReq.URL.Path,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
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,
upstreamModel,
safeUpstreamURL(upstreamReq.URL.String()),
resp,
writerSizeBeforeResponse,
@@ -1941,13 +1973,17 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
)
}
} else {
usage, imageCount, imageOutputSizes, err = s.handleOpenAIImagesOAuthNonStreamingResponse(resp, c, parsed.ResponseFormat, requestModel)
if direct {
usage, imageCount, imageOutputSizes, err = s.handleCodexDirectImagesNonStreamingResponse(resp, c, parsed)
} else {
usage, imageCount, imageOutputSizes, err = s.handleOpenAIImagesOAuthNonStreamingResponse(resp, c, parsed.ResponseFormat, requestModel)
}
if err != nil {
return nil, s.handleOpenAIImagesOAuthResponseError(
upstreamCtx,
c,
account,
requestModel,
upstreamModel,
safeUpstreamURL(upstreamReq.URL.String()),
resp,
writerSizeBeforeResponse,
@@ -1959,19 +1995,22 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
imageCount = parsed.N
}
return &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"),
UpstreamHeaders: resp.Header,
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,
RequestID: resp.Header.Get("x-request-id"),
UpstreamHeaders: resp.Header,
Usage: usage,
Model: requestModel,
UpstreamModel: upstreamModel,
UpstreamEndpoint: upstreamReq.URL.Path,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
Stream: parsed.Stream,
ResponseHeaders: resp.Header.Clone(),
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ImageCount: imageCount,
ImageSize: parsed.SizeTier,
ImageInputSize: parsed.Size,
ImageOutputSizes: imageOutputSizes,
}, nil
}
@@ -2075,6 +2114,12 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthResponseError(
if !errors.As(err, &upstreamErr) {
return err
}
if isOpenAIImagesMainModelError(upstreamErr.StatusCode, openAIImagesUpstreamErrorResponseBody(upstreamErr)) {
if !responseWritten {
writeOpenAIImagesUpstreamErrorResponse(c, upstreamErr)
}
return err
}
retryable := IsOpenAIImagesRetryableUpstreamError(upstreamErr)
kind := "http_error"
+26 -26
View File
@@ -791,7 +791,7 @@ func findOpenAIImageTestSSEEvent(events []openAIImageTestSSEEvent, name string)
func TestOpenAIGatewayServiceForwardImages_OAuthPassesNAndReturnsAllImages(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024","quality":"high","n":3}`)
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","size":"1024x1024","quality":"high","n":3}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
@@ -833,8 +833,8 @@ func TestOpenAIGatewayServiceForwardImages_OAuthPassesNAndReturnsAllImages(t *te
result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "gpt-image-2", result.Model)
require.Equal(t, "gpt-image-2", result.UpstreamModel)
require.Equal(t, "gpt-image-1", result.Model)
require.Equal(t, "gpt-image-1", result.UpstreamModel)
require.Equal(t, 3, result.ImageCount)
require.Equal(t, 46, result.Usage.InputTokens)
require.Equal(t, 2459, result.Usage.OutputTokens)
@@ -853,14 +853,14 @@ func TestOpenAIGatewayServiceForwardImages_OAuthPassesNAndReturnsAllImages(t *te
require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())
require.Equal(t, "image_generation", gjson.GetBytes(upstream.lastBody, "tools.0.type").String())
require.Equal(t, "generate", gjson.GetBytes(upstream.lastBody, "tools.0.action").String())
require.Equal(t, "gpt-image-2", gjson.GetBytes(upstream.lastBody, "tools.0.model").String())
require.Equal(t, "gpt-image-1", gjson.GetBytes(upstream.lastBody, "tools.0.model").String())
require.Equal(t, "1024x1024", gjson.GetBytes(upstream.lastBody, "tools.0.size").String())
require.Equal(t, "high", gjson.GetBytes(upstream.lastBody, "tools.0.quality").String())
require.Equal(t, int64(3), gjson.GetBytes(upstream.lastBody, "tools.0.n").Int())
require.Equal(t, "draw a cat", gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String())
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "gpt-image-2", gjson.Get(rec.Body.String(), "model").String())
require.Equal(t, "gpt-image-1", gjson.Get(rec.Body.String(), "model").String())
require.Len(t, gjson.Get(rec.Body.String(), "data").Array(), 3)
require.Equal(t, "aW1hZ2UtMQ==", gjson.Get(rec.Body.String(), "data.0.b64_json").String())
require.Equal(t, "aW1hZ2UtMg==", gjson.Get(rec.Body.String(), "data.1.b64_json").String())
@@ -946,7 +946,7 @@ func TestBoundedJSONNonNegativeInt(t *testing.T) {
func TestOpenAIGatewayServiceForwardImages_OAuthUpstreamHTTPErrorSurfacesRealError(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`)
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","response_format":"b64_json"}`)
// The non-failover upstream error path is shared by /generations and /edits;
// use /generations here so the request parses without an uploaded image.
@@ -1004,7 +1004,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthUpstreamHTTPErrorSurfacesRealErr
func TestOpenAIGatewayServiceForwardImages_OAuthNonStreamModerationBlockedReturnsClientError(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw blocked image","response_format":"b64_json"}`)
body := []byte(`{"model":"gpt-image-1","prompt":"draw blocked image","response_format":"b64_json"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
@@ -1057,7 +1057,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthNonStreamModerationBlockedReturn
func TestOpenAIGatewayServiceForwardImages_OAuthNonStreamServerErrorReturnsFailoverBeforeFlush(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`)
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","response_format":"b64_json"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
@@ -1114,7 +1114,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthNonStreamServerErrorReturnsFailo
func TestOpenAIGatewayServiceForwardImages_OAuth429CarriesSameAccountRetryWindow(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`)
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","response_format":"b64_json"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
@@ -1247,7 +1247,7 @@ func TestShouldClassifyOpenAIUpstreamStreamReadErrorTransportStrings(t *testing.
func TestOpenAIGatewayServiceForwardImages_OAuthStreamServerErrorAfterFlushDoesNotFailover(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`)
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
@@ -1657,7 +1657,7 @@ func TestOpenAIGatewayServiceForwardImages_APIKeyEditUsesConfiguredV1BaseURL(t *
func TestOpenAIGatewayServiceForwardImages_OAuthStreamingTransformsEvents(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"url"}`)
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","stream":true,"response_format":"url"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
@@ -1677,9 +1677,9 @@ func TestOpenAIGatewayServiceForwardImages_OAuthStreamingTransformsEvents(t *tes
"X-Request-Id": []string{"req_img_stream"},
},
Body: io.NopCloser(strings.NewReader(
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000001,\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-2\",\"background\":\"auto\",\"output_format\":\"png\",\"quality\":\"high\",\"size\":\"1024x1024\"}]}}\n\n" +
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000001,\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-1\",\"background\":\"auto\",\"output_format\":\"png\",\"quality\":\"high\",\"size\":\"1024x1024\"}]}}\n\n" +
"data: {\"type\":\"response.image_generation_call.partial_image\",\"partial_image_b64\":\"cGFydGlhbA==\",\"partial_image_index\":0,\"output_format\":\"png\",\"background\":\"auto\"}\n\n" +
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000001,\"usage\":{\"input_tokens\":5,\"output_tokens\":9,\"output_tokens_details\":{\"image_tokens\":4}},\"tool_usage\":{\"image_gen\":{\"input_tokens\":46,\"output_tokens\":2459,\"output_tokens_details\":{\"image_tokens\":2459},\"images\":1}},\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-2\",\"background\":\"auto\",\"output_format\":\"png\",\"quality\":\"high\",\"size\":\"1024x1024\"}],\"output\":[{\"type\":\"image_generation_call\",\"result\":\"ZmluYWw=\",\"output_format\":\"png\"}]}}\n\n" +
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000001,\"usage\":{\"input_tokens\":5,\"output_tokens\":9,\"output_tokens_details\":{\"image_tokens\":4}},\"tool_usage\":{\"image_gen\":{\"input_tokens\":46,\"output_tokens\":2459,\"output_tokens_details\":{\"image_tokens\":2459},\"images\":1}},\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-1\",\"background\":\"auto\",\"output_format\":\"png\",\"quality\":\"high\",\"size\":\"1024x1024\"}],\"output\":[{\"type\":\"image_generation_call\",\"result\":\"ZmluYWw=\",\"output_format\":\"png\"}]}}\n\n" +
"data: [DONE]\n\n",
)),
},
@@ -1709,7 +1709,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthStreamingTransformsEvents(t *tes
require.Equal(t, int64(1710000001), gjson.Get(partial.Data, "created_at").Int())
require.Equal(t, "cGFydGlhbA==", gjson.Get(partial.Data, "b64_json").String())
require.Equal(t, "data:image/png;base64,cGFydGlhbA==", gjson.Get(partial.Data, "url").String())
require.Equal(t, "gpt-image-2", gjson.Get(partial.Data, "model").String())
require.Equal(t, "gpt-image-1", gjson.Get(partial.Data, "model").String())
require.Equal(t, "png", gjson.Get(partial.Data, "output_format").String())
require.Equal(t, "high", gjson.Get(partial.Data, "quality").String())
require.Equal(t, "1024x1024", gjson.Get(partial.Data, "size").String())
@@ -1721,7 +1721,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthStreamingTransformsEvents(t *tes
require.Equal(t, int64(1710000001), gjson.Get(completed.Data, "created_at").Int())
require.Equal(t, "ZmluYWw=", gjson.Get(completed.Data, "b64_json").String())
require.Equal(t, "data:image/png;base64,ZmluYWw=", gjson.Get(completed.Data, "url").String())
require.Equal(t, "gpt-image-2", gjson.Get(completed.Data, "model").String())
require.Equal(t, "gpt-image-1", gjson.Get(completed.Data, "model").String())
require.Equal(t, "png", gjson.Get(completed.Data, "output_format").String())
require.Equal(t, "high", gjson.Get(completed.Data, "quality").String())
require.Equal(t, "1024x1024", gjson.Get(completed.Data, "size").String())
@@ -1790,7 +1790,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthEditsMultipartUsesResponsesAPI(t
var body bytes.Buffer
writer := multipart.NewWriter(&body)
require.NoError(t, writer.WriteField("model", "gpt-image-2"))
require.NoError(t, writer.WriteField("model", "gpt-image-1"))
require.NoError(t, writer.WriteField("prompt", "replace background with aurora"))
require.NoError(t, writer.WriteField("input_fidelity", "high"))
require.NoError(t, writer.WriteField("output_format", "webp"))
@@ -1854,7 +1854,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthEditsMultipartUsesResponsesAPI(t
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 1, result.ImageCount)
require.Equal(t, "gpt-image-2", gjson.GetBytes(upstream.lastBody, "tools.0.model").String())
require.Equal(t, "gpt-image-1", gjson.GetBytes(upstream.lastBody, "tools.0.model").String())
require.Equal(t, "edit", gjson.GetBytes(upstream.lastBody, "tools.0.action").String())
require.False(t, gjson.GetBytes(upstream.lastBody, "tools.0.input_fidelity").Exists())
require.Equal(t, "webp", gjson.GetBytes(upstream.lastBody, "tools.0.output_format").String())
@@ -1868,7 +1868,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthEditsMultipartUsesResponsesAPI(t
func TestOpenAIGatewayServiceForwardImages_OAuthEditsStreamingTransformsEvents(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{
"model":"gpt-image-2",
"model":"gpt-image-1",
"prompt":"replace background with aurora",
"images":[{"image_url":"https://example.com/source.png"}],
"mask":{"image_url":"https://example.com/mask.png"},
@@ -1893,9 +1893,9 @@ func TestOpenAIGatewayServiceForwardImages_OAuthEditsStreamingTransformsEvents(t
"Content-Type": []string{"text/event-stream"},
},
Body: io.NopCloser(strings.NewReader(
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000003,\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-2\",\"background\":\"transparent\",\"output_format\":\"webp\",\"quality\":\"high\",\"size\":\"1024x1024\"}]}}\n\n" +
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000003,\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-1\",\"background\":\"transparent\",\"output_format\":\"webp\",\"quality\":\"high\",\"size\":\"1024x1024\"}]}}\n\n" +
"data: {\"type\":\"response.image_generation_call.partial_image\",\"partial_image_b64\":\"cGFydGlhbA==\",\"partial_image_index\":0,\"output_format\":\"webp\",\"background\":\"transparent\"}\n\n" +
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000003,\"usage\":{\"input_tokens\":7,\"output_tokens\":10,\"output_tokens_details\":{\"image_tokens\":5}},\"tool_usage\":{\"image_gen\":{\"images\":1}},\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-2\",\"background\":\"transparent\",\"output_format\":\"webp\",\"quality\":\"high\",\"size\":\"1024x1024\"}],\"output\":[{\"type\":\"image_generation_call\",\"result\":\"ZWRpdGVk\",\"revised_prompt\":\"replace background with aurora\",\"output_format\":\"webp\"}]}}\n\n" +
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000003,\"usage\":{\"input_tokens\":7,\"output_tokens\":10,\"output_tokens_details\":{\"image_tokens\":5}},\"tool_usage\":{\"image_gen\":{\"images\":1}},\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-1\",\"background\":\"transparent\",\"output_format\":\"webp\",\"quality\":\"high\",\"size\":\"1024x1024\"}],\"output\":[{\"type\":\"image_generation_call\",\"result\":\"ZWRpdGVk\",\"revised_prompt\":\"replace background with aurora\",\"output_format\":\"webp\"}]}}\n\n" +
"data: [DONE]\n\n",
)),
},
@@ -1926,7 +1926,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthEditsStreamingTransformsEvents(t
require.Equal(t, int64(1710000003), gjson.Get(partial.Data, "created_at").Int())
require.Equal(t, "cGFydGlhbA==", gjson.Get(partial.Data, "b64_json").String())
require.Equal(t, "data:image/webp;base64,cGFydGlhbA==", gjson.Get(partial.Data, "url").String())
require.Equal(t, "gpt-image-2", gjson.Get(partial.Data, "model").String())
require.Equal(t, "gpt-image-1", gjson.Get(partial.Data, "model").String())
require.Equal(t, "webp", gjson.Get(partial.Data, "output_format").String())
require.Equal(t, "high", gjson.Get(partial.Data, "quality").String())
require.Equal(t, "1024x1024", gjson.Get(partial.Data, "size").String())
@@ -1938,7 +1938,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthEditsStreamingTransformsEvents(t
require.Equal(t, int64(1710000003), gjson.Get(completed.Data, "created_at").Int())
require.Equal(t, "ZWRpdGVk", gjson.Get(completed.Data, "b64_json").String())
require.Equal(t, "data:image/webp;base64,ZWRpdGVk", gjson.Get(completed.Data, "url").String())
require.Equal(t, "gpt-image-2", gjson.Get(completed.Data, "model").String())
require.Equal(t, "gpt-image-1", gjson.Get(completed.Data, "model").String())
require.Equal(t, "webp", gjson.Get(completed.Data, "output_format").String())
require.Equal(t, "high", gjson.Get(completed.Data, "quality").String())
require.Equal(t, "1024x1024", gjson.Get(completed.Data, "size").String())
@@ -2064,7 +2064,7 @@ func TestCollectOpenAIImagesFromResponsesBody_MultilineSSE(t *testing.T) {
func TestOpenAIGatewayServiceForwardImages_OAuthStreamingHandlesOutputItemDoneFallback(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"url"}`)
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","stream":true,"response_format":"url"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
@@ -2114,14 +2114,14 @@ func TestOpenAIGatewayServiceForwardImages_OAuthStreamingHandlesOutputItemDoneFa
require.Equal(t, int64(1710000005), gjson.Get(completed.Data, "created_at").Int())
require.Equal(t, "ZmluYWw=", gjson.Get(completed.Data, "b64_json").String())
require.Equal(t, "data:image/png;base64,ZmluYWw=", gjson.Get(completed.Data, "url").String())
require.Equal(t, "gpt-image-2", gjson.Get(completed.Data, "model").String())
require.Equal(t, "gpt-image-1", gjson.Get(completed.Data, "model").String())
require.JSONEq(t, `{"images":1}`, gjson.Get(completed.Data, "usage").Raw)
require.NotContains(t, rec.Body.String(), "event: error")
}
func TestOpenAIGatewayServiceForwardImages_OAuthStreamingHandlesMultilineSSE(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`)
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
@@ -2176,7 +2176,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthStreamingHandlesMultilineSSE(t *
func TestOpenAIGatewayServiceForwardImages_OAuthStreamingDrainsAfterClientDisconnect(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"url"}`)
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","stream":true,"response_format":"url"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
@@ -67,7 +67,7 @@ func TestOpenAIGatewayServiceGetAccessTokenSetupToken(t *testing.T) {
}
}
func TestOpenAISetupTokenImagesUsesOAuthResponsesPath(t *testing.T) {
func TestOpenAISetupTokenImagesUsesOAuthDirectPath(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
@@ -100,7 +100,7 @@ func TestOpenAISetupTokenImagesUsesOAuthResponsesPath(t *testing.T) {
require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode)
require.True(t, failoverErr.RetryableOnSameAccount)
require.False(t, failoverErr.SameAccountRetryDeadline.IsZero())
require.Contains(t, upstream.lastReq.URL.String(), "/backend-api/codex/responses")
require.Contains(t, upstream.lastReq.URL.String(), "/backend-api/codex/images/generations")
}
func TestOpenAISetupTokenWSCompatibility(t *testing.T) {
+6 -1
View File
@@ -42,7 +42,7 @@ var (
openAIGPTImage25FallbackPricing = &LiteLLMModelPricing{
InputCostPerToken: 5e-06,
CacheReadInputTokenCost: 1.25e-06,
InputCostPerImageToken: 8e-06,
InputCostPerImageToken: 8e-06, CacheReadInputImageTokenCost: 2e-06,
OutputCostPerImageToken: 3e-05,
LiteLLMProvider: "openai",
Mode: "image_generation",
@@ -155,6 +155,7 @@ type LiteLLMModelPricing struct {
OutputCostPerImage float64 `json:"output_cost_per_image"` // 图片生成模型每张图片价格
OutputCostPerImageToken float64 `json:"output_cost_per_image_token"` // 图片输出 token 价格
InputCostPerImageToken float64 `json:"input_cost_per_image_token"` // 图片输入 token 价格(如 gpt-image-2 图片编辑)
CacheReadInputImageTokenCost float64 `json:"cache_read_input_image_token_cost"`
// TokenPricingAbsent 表示源数据中 input/output token 价格均缺失(仅有图片价)。
// 此类条目只可用于图片计费,token 计费必须回退到 fallback 或 fail-closed,
@@ -189,6 +190,7 @@ type LiteLLMRawEntry struct {
OutputCostPerImage *float64 `json:"output_cost_per_image"`
OutputCostPerImageToken *float64 `json:"output_cost_per_image_token"`
InputCostPerImageToken *float64 `json:"input_cost_per_image_token"`
CacheReadInputImageTokenCost *float64 `json:"cache_read_input_image_token_cost"`
}
// PricingService 动态价格服务
@@ -657,6 +659,9 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel
if entry.InputCostPerImageToken != nil {
pricing.InputCostPerImageToken = *entry.InputCostPerImageToken
}
if entry.CacheReadInputImageTokenCost != nil {
pricing.CacheReadInputImageTokenCost = *entry.CacheReadInputImageTokenCost
}
hasExplicitLongContext := entry.LongContextInputTokenThreshold != nil ||
entry.LongContextInputCostMultiplier != nil ||
@@ -128,7 +128,7 @@ func TestOpenAIGatewayServiceForwardImages_ImageRateLimitReturnsFailoverAndCools
func TestOpenAIGatewayServiceForwardImages_TextFallbackDoesNotCoolImageCapability(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &modelNotFoundAccountRepoStub{}
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat"}`)
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat"}`)
upstreamSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r\",\"status\":\"completed\",\"model\":\"gpt-5.4-mini\",\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"Here's a polished image prompt for your request.\"}]}]}}\n\n"
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
@@ -178,7 +178,7 @@ func TestOpenAIGatewayServiceForwardImages_TextFallbackDoesNotCoolImageCapabilit
func TestOpenAIGatewayServiceForwardImages_StructuredUnavailableCoolsImageCapability(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &modelNotFoundAccountRepoStub{}
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat"}`)
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat"}`)
upstreamSSE := "data: {\"type\":\"response.failed\",\"response\":{\"id\":\"r\",\"error\":" +
"{\"type\":\"upstream_error\",\"code\":\"image_generation_unavailable\"," +
"\"message\":\"image generation tool is not available for this account\"}}}\n\n"