From c0d51193755c0141fefbbcf4f12e219010d95edd Mon Sep 17 00:00:00 2001 From: song Date: Wed, 9 Sep 2026 14:40:10 +0800 Subject: [PATCH] feat(openai): route OAuth image requests through native Codex Images Use shared model routing for image forwarding and account tests, retain Luna orchestration for legacy models, and add Image 2.5 pricing and usage accounting. --- backend/internal/config/config.go | 9 +- .../handler/openai_images_failover_test.go | 2 +- backend/internal/pkg/openai/constants.go | 2 + .../account_test_service_openai_image_test.go | 21 +- backend/internal/service/billing_service.go | 6 + .../service/image_output_accounting.go | 4 +- .../openai_gateway_response_handling.go | 3 + .../service/openai_gateway_service.go | 1 + .../internal/service/openai_gateway_usage.go | 13 +- backend/internal/service/openai_images.go | 73 +++++- .../service/openai_images_actual_size_test.go | 6 +- .../internal/service/openai_images_direct.go | 221 ++++++++++++++++++ .../openai_images_direct_payload_test.go | 123 ++++++++++ .../service/openai_images_direct_test.go | 217 +++++++++++++++++ .../openai_images_json_keepalive_test.go | 2 +- .../service/openai_images_responses.go | 127 ++++++---- .../internal/service/openai_images_test.go | 52 ++--- .../service/openai_setup_token_compat_test.go | 4 +- backend/internal/service/pricing_service.go | 40 ++-- .../ratelimit_service_openai_image_test.go | 4 +- .../model_prices_and_context_window.json | 128 +++++----- frontend/src/composables/useModelWhitelist.ts | 6 +- 22 files changed, 862 insertions(+), 202 deletions(-) create mode 100644 backend/internal/service/openai_images_direct.go create mode 100644 backend/internal/service/openai_images_direct_payload_test.go create mode 100644 backend/internal/service/openai_images_direct_test.go 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