mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user