diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f5853682ac..0819ac5e33 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1667,8 +1667,9 @@ type DefaultConfig struct { } type RateLimitConfig struct { - OverloadCooldownMinutes int `mapstructure:"overload_cooldown_minutes"` // 529过载冷却时间(分钟) - OAuth401CooldownMinutes int `mapstructure:"oauth_401_cooldown_minutes"` // OAuth 401临时不可调度冷却(分钟) + OverloadCooldownMinutes int `mapstructure:"overload_cooldown_minutes"` // 529过载冷却时间(分钟) + OAuth401CooldownMinutes int `mapstructure:"oauth_401_cooldown_minutes"` // OAuth 401临时不可调度冷却(分钟) + Gemini429CooldownMinutes int `mapstructure:"gemini_429_cooldown_minutes"` // Gemini 429 冷却时间(分钟) } // APIKeyAuthCacheConfig API Key 认证缓存配置 @@ -2360,7 +2361,7 @@ func setDefaults() { // Gateway viper.SetDefault("gateway.response_header_timeout", 600) // 600秒(10分钟)等待上游响应头,LLM高负载时可能排队较久 - viper.SetDefault("gateway.openai_response_header_timeout", 0) + viper.SetDefault("gateway.openai_response_header_timeout", 120) viper.SetDefault("gateway.grok_response_header_timeout", 120) viper.SetDefault("gateway.openai_first_output_timeout_seconds", 0) viper.SetDefault("gateway.openai_high_effort_first_output_timeout_seconds", 0) @@ -2479,7 +2480,7 @@ func setDefaults() { viper.SetDefault("gateway.max_upstream_clients", 5000) viper.SetDefault("gateway.client_idle_ttl_seconds", 900) viper.SetDefault("gateway.concurrency_slot_ttl_minutes", 30) // 并发槽位过期时间(支持超长请求) - viper.SetDefault("gateway.stream_data_interval_timeout", 180) + viper.SetDefault("gateway.stream_data_interval_timeout", 120) viper.SetDefault("gateway.stream_keepalive_interval", 10) viper.SetDefault("gateway.image_stream_data_interval_timeout", 900) viper.SetDefault("gateway.image_stream_keepalive_interval", 10) diff --git a/backend/internal/handler/openai_images_failover_test.go b/backend/internal/handler/openai_images_failover_test.go index 8b07cb9a52..73d1a610b7 100644 --- a/backend/internal/handler/openai_images_failover_test.go +++ b/backend/internal/handler/openai_images_failover_test.go @@ -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) diff --git a/backend/internal/pkg/openai/constants.go b/backend/internal/pkg/openai/constants.go index 5c7cad3674..abe73e75b3 100644 --- a/backend/internal/pkg/openai/constants.go +++ b/backend/internal/pkg/openai/constants.go @@ -35,6 +35,8 @@ var DefaultModels = []Model{ {ID: "gpt-image-2", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 2"}, {ID: "gpt-image-2.5-flare", Object: "model", Created: 1788825600, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 2.5 Flare"}, {ID: "gpt-image-2.5-sunburst", Object: "model", Created: 1788825600, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 2.5 Sunburst"}, + {ID: "gpt-image-2.5-flare-2026-09-08", Object: "model", Created: 1788825600, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 2.5 Flare (2026-09-08)"}, + {ID: "gpt-image-2.5-sunburst-2026-09-08", Object: "model", Created: 1788825600, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 2.5 Sunburst (2026-09-08)"}, } // DefaultModelIDs returns the default model ID list diff --git a/backend/internal/service/account_test_service_openai_image_test.go b/backend/internal/service/account_test_service_openai_image_test.go index 31bfaa43a2..22dce16c3c 100644 --- a/backend/internal/service/account_test_service_openai_image_test.go +++ b/backend/internal/service/account_test_service_openai_image_test.go @@ -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") -} diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index c22d24690f..55e4f08d9b 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -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 diff --git a/backend/internal/service/image_output_accounting.go b/backend/internal/service/image_output_accounting.go index 4ae6c5d7ca..0b63ca9270 100644 --- a/backend/internal/service/image_output_accounting.go +++ b/backend/internal/service/image_output_accounting.go @@ -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") { diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index d5b35e8585..2563dbc3e3 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -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 } diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index df7e66eb87..292cd45495 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -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"` diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 4570ed1631..6bd24cb58c 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -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 diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index 74058104fe..ccde0cbf73 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -934,7 +934,12 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse( resp *http.Response, c *gin.Context, startTime time.Time, + directRequests ...*OpenAIImagesRequest, ) (OpenAIUsage, int, []string, *int, error) { + var direct *OpenAIImagesRequest + if len(directRequests) > 0 { + direct = directRequests[0] + } responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) if contentType == "" { @@ -959,13 +964,72 @@ 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() < max(direct.N, 1) { + return newOpenAIUpstreamStreamReadError(ErrOpenAIUpstreamStreamTruncated) + } + return nil + } processSSEData := func(dataBytes []byte) { 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 !gjson.ValidBytes(dataBytes) { + streamErr = newOpenAIUpstreamStreamReadError(fmt.Errorf("invalid image stream JSON")) + return + } + eventType := gjson.GetBytes(dataBytes, "type").String() + // 原生编辑事件可能仍使用 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 +1044,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 +1071,9 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse( } finalizeFallbackBody := func() { + if direct != nil { + return + } if seenSSEData || fallbackBody.Len() == 0 { return } @@ -1035,7 +1102,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 +1169,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() diff --git a/backend/internal/service/openai_images_actual_size_test.go b/backend/internal/service/openai_images_actual_size_test.go index 4455e415a0..5d904d9af9 100644 --- a/backend/internal/service/openai_images_actual_size_test.go +++ b/backend/internal/service/openai_images_actual_size_test.go @@ -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, ) diff --git a/backend/internal/service/openai_images_direct.go b/backend/internal/service/openai_images_direct.go new file mode 100644 index 0000000000..f10d444a98 --- /dev/null +++ b/backend/internal/service/openai_images_direct.go @@ -0,0 +1,221 @@ +package service + +import ( + "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" +) + +// 显式列出已接入的模型,不把未来模型或未知快照自动送到直调端点。 +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 参数保持透传;multipart 则将已校验的字段与上传图片转换为 JSON。 + body := []byte(`{}`) + if !parsed.Multipart && gjson.ValidBytes(parsed.Body) { + body = append([]byte(nil), parsed.Body...) + } + body, _ = sjson.SetBytes(body, "model", model) + if !gjson.GetBytes(body, "prompt").Exists() { + body, _ = sjson.SetBytes(body, "prompt", parsed.Prompt) + } + for _, field := range []struct{ key, 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 field.value != "" && !gjson.GetBytes(body, field.key).Exists() { + body, _ = sjson.SetBytes(body, field.key, field.value) + } + } + if parsed.N > 0 { + body, _ = sjson.SetBytes(body, "n", parsed.N) + } + if parsed.OutputCompression != nil { + body, _ = sjson.SetBytes(body, "output_compression", *parsed.OutputCompression) + } + if parsed.PartialImages != nil { + body, _ = sjson.SetBytes(body, "partial_images", *parsed.PartialImages) + } + // GPT Image 原生返回 base64;对外 url 格式由网关转换为 data URL。 + body, _ = sjson.DeleteBytes(body, "response_format") + if parsed.Stream { + body, _ = sjson.SetBytes(body, "stream", true) + } else { + body, _ = sjson.DeleteBytes(body, "stream") + } + endpoint := "/images/generations" + if parsed.IsEdits() { + endpoint = "/images/edits" + images := make([]map[string]string, 0, len(parsed.InputImageURLs)+len(parsed.Uploads)) + for _, url := range parsed.InputImageURLs { + images = append(images, map[string]string{"image_url": url}) + } + 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") + } + body, _ = sjson.SetBytes(body, "images", images) + mask := parsed.MaskImageURL + if parsed.MaskUpload != nil { + var err error + mask, err = openAIImageUploadToDataURL(*parsed.MaskUpload) + if err != nil { + return nil, "", err + } + } + if mask != "" { + body, _ = sjson.SetBytes(body, "mask.image_url", mask) + } + } + 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 { + return isOpenAICodexPlanGatedModelError(status, body) && + strings.Contains(extractUpstreamErrorMessage(body), openAIImagesResponsesMainModel) +} + +// 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) + } + } + 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) + c.Data(resp.StatusCode, "application/json", body) + return usage, len(results), openAIResponsesImageResultSizes(results), nil +} diff --git a/backend/internal/service/openai_images_direct_payload_test.go b/backend/internal/service/openai_images_direct_payload_test.go new file mode 100644 index 0000000000..9f99737f87 --- /dev/null +++ b/backend/internal/service/openai_images_direct_payload_test.go @@ -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) +} diff --git a/backend/internal/service/openai_images_direct_test.go b/backend/internal/service/openai_images_direct_test.go new file mode 100644 index 0000000000..6ab08e2bad --- /dev/null +++ b/backend/internal/service/openai_images_direct_test.go @@ -0,0 +1,217 @@ +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.True(t, gjson.GetBytes(upstream.lastBody, "extra.preserve").Bool()) + 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 TestCodexDirectImagesHTTPErrorDoesNotFallback(t *testing.T) { + for _, status := range []int{400, 401, 403, 429, 500, 502, 503} { + 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++ + 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 + }} + 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) + _, err = svc.ForwardImages(context.Background(), c, directImagesTestAccount(), body, parsed, "") + 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}, + {"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"}`) + 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) + } +} diff --git a/backend/internal/service/openai_images_json_keepalive_test.go b/backend/internal/service/openai_images_json_keepalive_test.go index a7207b3adb..e231cfc968 100644 --- a/backend/internal/service/openai_images_json_keepalive_test.go +++ b/backend/internal/service/openai_images_json_keepalive_test.go @@ -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") diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index 1b7da71b96..5a0f667fb8 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "sync/atomic" "time" @@ -382,7 +383,7 @@ func buildOpenAIImagesResponsesRequest(parsed *OpenAIImagesRequest, toolModel st } req := []byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"}}`) - req, _ = sjson.SetBytes(req, "model", openAIImagesResponsesMainModelValue()) + req, _ = sjson.SetBytes(req, "model", openAIImagesResponsesMainModel) req, _ = sjson.SetBytes(req, "instructions", openAIImagesVerbatimPromptInstructions) input := []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`) @@ -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) + 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,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( return nil, err } - responsesBody, err := buildOpenAIImagesResponsesRequest(parsed, requestModel) + responsesBody, targetURL, err := buildOpenAIImagesOAuthPayload(parsed, upstreamModel) if err != nil { return nil, err } @@ -1831,9 +1831,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 { @@ -1870,6 +1882,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 +1900,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 +1911,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 +1925,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 +1963,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, upstreamModel) + } if err != nil { return nil, s.handleOpenAIImagesOAuthResponseError( upstreamCtx, c, account, - requestModel, + upstreamModel, safeUpstreamURL(upstreamReq.URL.String()), resp, writerSizeBeforeResponse, @@ -1959,19 +1985,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 +2104,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" diff --git a/backend/internal/service/openai_images_test.go b/backend/internal/service/openai_images_test.go index f06c213f46..01ec82e763 100644 --- a/backend/internal/service/openai_images_test.go +++ b/backend/internal/service/openai_images_test.go @@ -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") diff --git a/backend/internal/service/openai_setup_token_compat_test.go b/backend/internal/service/openai_setup_token_compat_test.go index 8f2f2bfcee..09dd2dae78 100644 --- a/backend/internal/service/openai_setup_token_compat_test.go +++ b/backend/internal/service/openai_setup_token_compat_test.go @@ -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) { diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index a585487191..8ecc0eaa5b 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -37,16 +37,12 @@ var ( // cache_creation_input_token_cost_above_1hr_above_200k_tokens 等)。 // 组 1 为基础价字段名主干,组 2 为 1h 缓存时长段(可为空),组 3 为服务档后缀(可为空)。 cacheTierPricePattern = regexp.MustCompile(`^(cache_(?:creation|read)_input_token_cost)(_above_1hr)?_above_\d+k_tokens((?:_[a-z]+)?)$`) - // Official GPT Image 2.5 token rates (2026-09-08): - // https://developers.openai.com/api/docs/pricing#image-generation-models - openAIGPTImage25FallbackPricing = &LiteLLMModelPricing{ - InputCostPerToken: 5e-06, - CacheReadInputTokenCost: 1.25e-06, - InputCostPerImageToken: 8e-06, - OutputCostPerImageToken: 3e-05, - LiteLLMProvider: "openai", - Mode: "image_generation", - SupportsPromptCaching: true, + // 官方 Image 2.5 价格独立兜底,避免远程目录滞后时命中旧图片模型费率。 + openAIImage25FallbackPricing = &LiteLLMModelPricing{ + InputCostPerToken: 5e-6, CacheReadInputTokenCost: 1.25e-6, + InputCostPerImageToken: 8e-6, CacheReadInputImageTokenCost: 2e-6, + OutputCostPerImageToken: 30e-6, + LiteLLMProvider: "openai", Mode: "image_generation", SupportsPromptCaching: true, } openAIGPT54FallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 2.5e-06, // $2.5 per MTok @@ -152,9 +148,10 @@ type LiteLLMModelPricing struct { LiteLLMProvider string `json:"litellm_provider"` Mode string `json:"mode"` SupportsPromptCaching bool `json:"supports_prompt_caching"` - 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 图片编辑) + 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 +186,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 +655,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 || @@ -1439,7 +1440,7 @@ func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing { // 2. gpt-5.2-codex -> gpt-5.2(去掉后缀如 -codex, -mini, -max 等) // 3. gpt-5.2-20251222 -> gpt-5.2(去掉日期版本号) // 4. gpt-5.3-codex -> gpt-5.2-codex -// 5. gpt-5.4* -> 业务静态兜底价 +// 5. gpt-5.4* / gpt-5.6* -> 业务静态兜底价 // 6. 最终回退到 DefaultTestModel (gpt-5.1-codex) func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing { if strings.HasPrefix(model, "gpt-5.3-codex-spark") { @@ -1517,14 +1518,11 @@ func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing { return openAIGPT54FallbackPricing } - // Remote price mirrors can lag new releases. Never bill GPT Image 2.5 - // using the older image model's rates when its entry is absent. - for _, imageModel := range []string{"gpt-image-2.5-flare", "gpt-image-2.5-sunburst"} { - if model == imageModel || model == imageModel+"-2026-09-08" { - return openAIGPTImage25FallbackPricing - } - } if isOpenAIImageGenerationModel(model) { + switch model { + case "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 openAIImage25FallbackPricing + } for _, candidate := range []string{"gpt-image-2", "gpt-image-1.5", "gpt-image-1"} { if pricing, ok := s.pricingData[candidate]; ok { logger.LegacyPrintf("service.pricing", "[Pricing] OpenAI image fallback matched %s -> %s", model, candidate) diff --git a/backend/internal/service/ratelimit_service_openai_image_test.go b/backend/internal/service/ratelimit_service_openai_image_test.go index bb844b66db..55a3b2caeb 100644 --- a/backend/internal/service/ratelimit_service_openai_image_test.go +++ b/backend/internal/service/ratelimit_service_openai_image_test.go @@ -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" diff --git a/backend/resources/model-pricing/model_prices_and_context_window.json b/backend/resources/model-pricing/model_prices_and_context_window.json index 579d18db86..5e658196e6 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -6348,68 +6348,68 @@ "supports_vision": true, "supports_web_search": true }, - "gpt-image-2.5-flare": { - "cache_read_input_image_token_cost": 2e-06, - "cache_read_input_token_cost": 1.25e-06, - "input_cost_per_image_token": 8e-06, - "input_cost_per_token": 5e-06, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_image_token": 3e-05, - "output_cost_per_token": 0, - "supported_endpoints": [ - "/v1/images/generations", - "/v1/images/edits" - ], - "supports_vision": true, - "supports_prompt_caching": true - }, - "gpt-image-2.5-flare-2026-09-08": { - "cache_read_input_image_token_cost": 2e-06, - "cache_read_input_token_cost": 1.25e-06, - "input_cost_per_image_token": 8e-06, - "input_cost_per_token": 5e-06, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_image_token": 3e-05, - "output_cost_per_token": 0, - "supported_endpoints": [ - "/v1/images/generations", - "/v1/images/edits" - ], - "supports_vision": true, - "supports_prompt_caching": true - }, - "gpt-image-2.5-sunburst": { - "cache_read_input_image_token_cost": 2e-06, - "cache_read_input_token_cost": 1.25e-06, - "input_cost_per_image_token": 8e-06, - "input_cost_per_token": 5e-06, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_image_token": 3e-05, - "output_cost_per_token": 0, - "supported_endpoints": [ - "/v1/images/generations", - "/v1/images/edits" - ], - "supports_vision": true, - "supports_prompt_caching": true - }, - "gpt-image-2.5-sunburst-2026-09-08": { - "cache_read_input_image_token_cost": 2e-06, - "cache_read_input_token_cost": 1.25e-06, - "input_cost_per_image_token": 8e-06, - "input_cost_per_token": 5e-06, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_image_token": 3e-05, - "output_cost_per_token": 0, - "supported_endpoints": [ - "/v1/images/generations", - "/v1/images/edits" - ], - "supports_vision": true, - "supports_prompt_caching": true - } + "gpt-image-2.5-flare": { + "cache_read_input_image_token_cost": 0.000002, + "cache_read_input_token_cost": 0.00000125, + "input_cost_per_image_token": 0.000008, + "input_cost_per_token": 0.000005, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_image_token": 0.00003, + "output_cost_per_token": 0, + "supported_endpoints": [ + "/v1/images/generations", + "/v1/images/edits" + ], + "supports_vision": true, + "supports_prompt_caching": true + }, + "gpt-image-2.5-flare-2026-09-08": { + "cache_read_input_image_token_cost": 0.000002, + "cache_read_input_token_cost": 0.00000125, + "input_cost_per_image_token": 0.000008, + "input_cost_per_token": 0.000005, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_image_token": 0.00003, + "output_cost_per_token": 0, + "supported_endpoints": [ + "/v1/images/generations", + "/v1/images/edits" + ], + "supports_vision": true, + "supports_prompt_caching": true + }, + "gpt-image-2.5-sunburst": { + "cache_read_input_image_token_cost": 0.000002, + "cache_read_input_token_cost": 0.00000125, + "input_cost_per_image_token": 0.000008, + "input_cost_per_token": 0.000005, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_image_token": 0.00003, + "output_cost_per_token": 0, + "supported_endpoints": [ + "/v1/images/generations", + "/v1/images/edits" + ], + "supports_vision": true, + "supports_prompt_caching": true + }, + "gpt-image-2.5-sunburst-2026-09-08": { + "cache_read_input_image_token_cost": 0.000002, + "cache_read_input_token_cost": 0.00000125, + "input_cost_per_image_token": 0.000008, + "input_cost_per_token": 0.000005, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_image_token": 0.00003, + "output_cost_per_token": 0, + "supported_endpoints": [ + "/v1/images/generations", + "/v1/images/edits" + ], + "supports_vision": true, + "supports_prompt_caching": true + } } diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index feed1c37f4..c9a205f7bf 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -4,6 +4,8 @@ // OpenAI const openaiModels = [ + // GPT-5.6 系列 + 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', // GPT-5.2 系列 'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest', 'gpt-5.2-pro', 'gpt-5.2-pro-2025-12-11', @@ -19,7 +21,9 @@ const openaiModels = [ 'gpt-5.3-codex-spark', 'codex-auto-review', 'gpt-4o-audio-preview', 'gpt-4o-realtime-preview', // GPT Image 系列 - 'gpt-image-1', 'gpt-image-1.5', 'gpt-image-2', 'gpt-image-2.5-flare', 'gpt-image-2.5-sunburst' + 'gpt-image-1', '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' ] // Anthropic Claude