mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 16:23:31 +00:00
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.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,11 +11,9 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestAccountTestService_OpenAIImageOAuthHandlesOutputItemDoneFallback(t *testing.T) {
|
||||
t.Setenv("SUB2API_IMAGES_MAIN_MODEL", "gpt-5.6-sol")
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
@@ -45,11 +43,9 @@ func TestAccountTestService_OpenAIImageOAuthHandlesOutputItemDoneFallback(t *tes
|
||||
},
|
||||
}
|
||||
|
||||
err := svc.testOpenAIImageOAuth(c, context.Background(), account, "gpt-image-2.5-flare", "draw a cat")
|
||||
err := svc.testOpenAIImageOAuth(c, context.Background(), account, "gpt-image-1", "draw a cat")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, upstream.lastReq)
|
||||
require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
require.Equal(t, "gpt-image-2.5-flare", gjson.GetBytes(upstream.lastBody, "tools.0.model").String())
|
||||
require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context()))
|
||||
require.Contains(t, rec.Body.String(), "Calling Codex /responses image tool")
|
||||
require.Contains(t, rec.Body.String(), "data:image/png;base64,aGVsbG8=")
|
||||
@@ -95,18 +91,3 @@ func TestAccountTestService_OpenAIImageAPIKeyUsesConfiguredV1BaseURL(t *testing.
|
||||
require.Contains(t, rec.Body.String(), "data:image/png;base64,aGVsbG8=")
|
||||
require.Contains(t, rec.Body.String(), "\"success\":true")
|
||||
}
|
||||
|
||||
func TestAccountTestService_OpenAIImageOAuthSurfacesSSEError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/1/test", nil)
|
||||
svc := &AccountTestService{httpUpstream: &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader("data: {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The selected image model is unavailable\"}}\n\n")),
|
||||
}}}
|
||||
account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"access_token": "test"}}
|
||||
err := svc.testOpenAIImageOAuth(c, context.Background(), account, "gpt-image-2.5-flare", "draw a cup")
|
||||
require.ErrorContains(t, err, "The selected image model is unavailable")
|
||||
require.NotContains(t, rec.Body.String(), "No images returned")
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ type ModelPricing struct {
|
||||
InputPricePerToken float64 // 每token输入价格 (USD)
|
||||
InputPricePerTokenPriority float64 // priority service tier 下每token输入价格 (USD)
|
||||
ImageInputPricePerToken float64 // 图片输入 token 价格 (USD),用于多模态 embedding 等图文不同价场景;为 0 时回退到 InputPricePerToken
|
||||
ImageCacheReadPricePerToken float64 // 图片缓存输入价格;无独立价格时沿用缓存读取价
|
||||
OutputPricePerToken float64 // 每token输出价格 (USD)
|
||||
OutputPricePerTokenPriority float64 // priority service tier 下每token输出价格 (USD)
|
||||
CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD)
|
||||
@@ -179,6 +180,7 @@ func pricingWithPriorityMultiplier(base *ModelPricing, multiplier float64) *Mode
|
||||
type UsageTokens struct {
|
||||
InputTokens int
|
||||
ImageInputTokens int
|
||||
ImageCacheReadTokens int
|
||||
OutputTokens int
|
||||
CacheCreationTokens int
|
||||
CacheReadTokens int
|
||||
@@ -1273,6 +1275,7 @@ func (s *BillingService) getModelPricingAt(model string, pricingAt time.Time) (*
|
||||
LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier,
|
||||
LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier,
|
||||
ImageInputPricePerToken: litellmPricing.InputCostPerImageToken,
|
||||
ImageCacheReadPricePerToken: litellmPricing.CacheReadInputImageTokenCost,
|
||||
ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken,
|
||||
}, true, pricingAt), nil
|
||||
}
|
||||
@@ -1595,6 +1598,9 @@ func (s *BillingService) computeTokenBreakdown(
|
||||
bd.CacheCreationCost = s.computeCacheCreationCost(pricing, tokens, cacheCreationPrice, cacheCreationMultiplier)
|
||||
|
||||
bd.CacheReadCost = float64(tokens.CacheReadTokens) * cacheReadPrice
|
||||
if imageCached := min(max(tokens.ImageCacheReadTokens, 0), max(tokens.CacheReadTokens, 0)); imageCached > 0 && pricing.ImageCacheReadPricePerToken > 0 {
|
||||
bd.CacheReadCost = float64(tokens.CacheReadTokens-imageCached)*cacheReadPrice + float64(imageCached)*pricing.ImageCacheReadPricePerToken
|
||||
}
|
||||
|
||||
if tierMultiplier != 1.0 {
|
||||
bd.InputCost *= tierMultiplier
|
||||
|
||||
@@ -74,7 +74,7 @@ func (c *openAIImageOutputCounter) AddSSEData(data []byte) {
|
||||
c.addImageOutputItem(root.Get("item"))
|
||||
case "response.completed", "response.done":
|
||||
c.addOutputArray(root.Get("response.output"))
|
||||
case "image_generation.completed":
|
||||
case "image_generation.completed", "image_edit.completed":
|
||||
if item := root.Get("item"); item.Exists() {
|
||||
c.addImageOutputItem(item)
|
||||
return
|
||||
@@ -138,7 +138,7 @@ func (c *openAIImageOutputCounter) addImageOutputItem(item gjson.Result) {
|
||||
return
|
||||
}
|
||||
itemType := strings.TrimSpace(item.Get("type").String())
|
||||
if itemType != "" && itemType != "image_generation_call" && itemType != "image_generation.completed" {
|
||||
if itemType != "" && itemType != "image_generation_call" && itemType != "image_generation.completed" && itemType != "image_edit.completed" {
|
||||
return
|
||||
}
|
||||
if strings.Contains(strings.ToLower(item.Raw), "partial_image") {
|
||||
|
||||
@@ -1239,6 +1239,9 @@ func mergeOpenAIUsageNonZero(dst *OpenAIUsage, src OpenAIUsage) {
|
||||
if src.ImageInputTokens > 0 {
|
||||
dst.ImageInputTokens = src.ImageInputTokens
|
||||
}
|
||||
if src.ImageCacheReadTokens > 0 {
|
||||
dst.ImageCacheReadTokens = src.ImageCacheReadTokens
|
||||
}
|
||||
if src.OutputTokens > 0 {
|
||||
dst.OutputTokens = src.OutputTokens
|
||||
}
|
||||
|
||||
@@ -223,6 +223,7 @@ func (s *OpenAICodexUsageSnapshot) Normalize() *NormalizedCodexLimits {
|
||||
type OpenAIUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
ImageInputTokens int `json:"image_input_tokens,omitempty"`
|
||||
ImageCacheReadTokens int `json:"image_cache_read_tokens,omitempty"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"`
|
||||
CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"`
|
||||
|
||||
@@ -185,12 +185,13 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
|
||||
// Calculate cost
|
||||
tokens := UsageTokens{
|
||||
InputTokens: actualInputTokens,
|
||||
ImageInputTokens: result.Usage.ImageInputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
||||
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
||||
ImageOutputTokens: result.Usage.ImageOutputTokens,
|
||||
InputTokens: actualInputTokens,
|
||||
ImageInputTokens: max(result.Usage.ImageInputTokens-result.Usage.ImageCacheReadTokens, 0),
|
||||
ImageCacheReadTokens: result.Usage.ImageCacheReadTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
||||
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
||||
ImageOutputTokens: result.Usage.ImageOutputTokens,
|
||||
}
|
||||
|
||||
// Get rate multiplier
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -74,7 +74,7 @@ type openAIOAuthImageActualSizeTestRun struct {
|
||||
func runOpenAIOAuthImageActualSizeTest(t *testing.T, stream bool) openAIOAuthImageActualSizeTestRun {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(fmt.Sprintf(`{"model":"gpt-image-2","prompt":"draw a test chart","size":"3840x2160","quality":"low","output_format":"png","stream":%t}`, stream))
|
||||
body := []byte(fmt.Sprintf(`{"model":"gpt-image-1","prompt":"draw a test chart","size":"3840x2160","quality":"low","output_format":"png","stream":%t}`, stream))
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -84,8 +84,8 @@ func runOpenAIOAuthImageActualSizeTest(t *testing.T, stream bool) openAIOAuthIma
|
||||
|
||||
encoded := encodeOpenAIImageTestPNG(t, 1672, 941)
|
||||
upstreamBody := fmt.Sprintf(
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000000,\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-2\",\"size\":\"auto\",\"quality\":\"auto\",\"output_format\":\"png\"}]}}\n\n"+
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000000,\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-2\",\"size\":\"auto\",\"quality\":\"auto\",\"output_format\":\"png\"}],\"output\":[{\"id\":\"ig_actual_size\",\"type\":\"image_generation_call\",\"result\":%q}]}}\n\n"+
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000000,\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-1\",\"size\":\"auto\",\"quality\":\"auto\",\"output_format\":\"png\"}]}}\n\n"+
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000000,\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-1\",\"size\":\"auto\",\"quality\":\"auto\",\"output_format\":\"png\"}],\"output\":[{\"id\":\"ig_actual_size\",\"type\":\"image_generation_call\",\"result\":%q}]}}\n\n"+
|
||||
"data: [DONE]\n\n",
|
||||
encoded,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestCodexDirectImagesMultipartEdit(t *testing.T) {
|
||||
var body bytes.Buffer
|
||||
w := multipart.NewWriter(&body)
|
||||
for key, value := range map[string]string{"model": "gpt-image-2.5-sunburst", "prompt": "编辑", "quality": "xhigh", "n": "2", "output_format": "webp", "output_compression": "75", "partial_images": "2", "input_fidelity": "high"} {
|
||||
require.NoError(t, w.WriteField(key, value))
|
||||
}
|
||||
for _, field := range []string{"image[]", "image[]", "mask"} {
|
||||
header := textproto.MIMEHeader{}
|
||||
header.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename="image.png"`, field))
|
||||
header.Set("Content-Type", "image/png")
|
||||
part, err := w.CreatePart(header)
|
||||
require.NoError(t, err)
|
||||
_, err = part.Write([]byte("png-content"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, w.Close())
|
||||
c, _ := newOpenAIImagesTestContext(t, body.Bytes())
|
||||
c.Request.URL.Path = "/v1/images/edits"
|
||||
c.Request.Header.Set("Content-Type", w.FormDataContentType())
|
||||
svc := newOpenAIImagesTestService(nil)
|
||||
parsed, err := svc.ParseOpenAIImagesRequest(c, body.Bytes())
|
||||
require.NoError(t, err)
|
||||
upstreamBody, target, err := buildOpenAIImagesOAuthPayload(parsed, parsed.Model)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://chatgpt.com/backend-api/codex/images/edits", target)
|
||||
require.Len(t, gjson.GetBytes(upstreamBody, "images").Array(), 2)
|
||||
require.Equal(t, "data:image/png;base64,cG5nLWNvbnRlbnQ=", gjson.GetBytes(upstreamBody, "mask.image_url").String())
|
||||
require.Equal(t, "xhigh", gjson.GetBytes(upstreamBody, "quality").String())
|
||||
require.Equal(t, "high", gjson.GetBytes(upstreamBody, "input_fidelity").String())
|
||||
require.EqualValues(t, 2, gjson.GetBytes(upstreamBody, "n").Int())
|
||||
require.EqualValues(t, 75, gjson.GetBytes(upstreamBody, "output_compression").Int())
|
||||
require.EqualValues(t, 2, gjson.GetBytes(upstreamBody, "partial_images").Int())
|
||||
require.False(t, gjson.GetBytes(upstreamBody, "tools").Exists())
|
||||
}
|
||||
|
||||
func TestCodexDirectImagesPricingAndUsage(t *testing.T) {
|
||||
prices := &PricingService{pricingData: map[string]*LiteLLMModelPricing{"gpt-image-2": {InputCostPerToken: 1}}}
|
||||
for _, model := range []string{"gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2.5-flare-2026-09-08", "gpt-image-2.5-sunburst-2026-09-08"} {
|
||||
price := prices.matchOpenAIModel(model)
|
||||
require.Equal(t, 5e-6, price.InputCostPerToken)
|
||||
require.Equal(t, 8e-6, price.InputCostPerImageToken)
|
||||
require.Equal(t, 30e-6, price.OutputCostPerImageToken)
|
||||
require.Equal(t, 2e-6, price.CacheReadInputImageTokenCost)
|
||||
}
|
||||
body, err := os.ReadFile("../../resources/model-pricing/model_prices_and_context_window.json")
|
||||
require.NoError(t, err)
|
||||
var catalog map[string]LiteLLMModelPricing
|
||||
require.NoError(t, json.Unmarshal(body, &catalog))
|
||||
require.Equal(t, 2e-6, catalog["gpt-image-2.5-flare"].CacheReadInputImageTokenCost)
|
||||
usage, ok := codexDirectImagesUsage([]byte(`{"usage":{"input_tokens":100,"input_tokens_details":{"text_tokens":20,"image_tokens":80,"cached_tokens":50,"cached_tokens_details":{"text_tokens":10,"image_tokens":40}},"output_tokens":200}}`))
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 40, usage.ImageCacheReadTokens)
|
||||
require.Equal(t, 200, usage.ImageOutputTokens)
|
||||
tokens := UsageTokens{InputTokens: usage.InputTokens - usage.CacheReadInputTokens, ImageInputTokens: usage.ImageInputTokens - usage.ImageCacheReadTokens, CacheReadTokens: usage.CacheReadInputTokens, ImageCacheReadTokens: usage.ImageCacheReadTokens, OutputTokens: usage.OutputTokens, ImageOutputTokens: usage.ImageOutputTokens}
|
||||
price := &ModelPricing{InputPricePerToken: 5e-6, ImageInputPricePerToken: 8e-6, CacheReadPricePerToken: 1.25e-6, ImageCacheReadPricePerToken: 2e-6, ImageOutputPricePerToken: 30e-6}
|
||||
cost := (&BillingService{}).computeTokenBreakdown(price, tokens, 1, "", false)
|
||||
require.InDelta(t, 10*5e-6, cost.InputCost, 1e-12)
|
||||
require.InDelta(t, 40*8e-6, cost.ImageInputCost, 1e-12)
|
||||
require.InDelta(t, 10*1.25e-6+40*2e-6, cost.CacheReadCost, 1e-12)
|
||||
require.InDelta(t, 200*30e-6, cost.ImageOutputCost, 1e-12)
|
||||
unknown, _ := codexDirectImagesUsage([]byte(`{"usage":{"input_tokens":100,"input_tokens_details":{"image_tokens":80,"cached_tokens":50},"output_tokens":200}}`))
|
||||
require.Zero(t, unknown.ImageCacheReadTokens, "不能根据总缓存量推测图片缓存占比")
|
||||
}
|
||||
|
||||
func TestCodexImagesLunaErrorDoesNotCoolImageAccount(t *testing.T) {
|
||||
for _, stream := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(stream), func(t *testing.T) {
|
||||
body := []byte(fmt.Sprintf(`{"model":"gpt-image-1","prompt":"draw","stream":%t}`, stream))
|
||||
c, rec := newOpenAIImagesTestContext(t, body)
|
||||
upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||
require.Equal(t, "/backend-api/codex/responses", req.URL.Path)
|
||||
return &http.Response{StatusCode: 400, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"The 'gpt-5.6-luna' model is not supported when using Codex with a ChatGPT account."}}`))}, nil
|
||||
}}
|
||||
svc := newOpenAIImagesTestService(upstream)
|
||||
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
|
||||
require.NoError(t, err)
|
||||
result, err := svc.ForwardImages(context.Background(), c, directImagesTestAccount(), body, parsed, "")
|
||||
require.Nil(t, result)
|
||||
var upstreamErr *OpenAIImagesUpstreamError
|
||||
require.ErrorAs(t, err, &upstreamErr)
|
||||
require.Contains(t, rec.Body.String(), "gpt-5.6-luna")
|
||||
var failover *UpstreamFailoverError
|
||||
require.NotErrorAs(t, err, &failover)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexDirectImagesShadowCredentials(t *testing.T) {
|
||||
parent := directImagesTestAccount()
|
||||
parent.Status = StatusActive
|
||||
shadow := &Account{ID: 99, ParentAccountID: &parent.ID, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw"}`)
|
||||
c, _ := newOpenAIImagesTestContext(t, body)
|
||||
upstream := &httpUpstreamRecorder{resp: openAIImagesJSONResponse()}
|
||||
svc := newOpenAIImagesTestService(upstream)
|
||||
svc.accountRepo = newStubCredRepo(parent)
|
||||
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.ForwardImages(context.Background(), c, shadow, body, parsed, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Bearer test-token", upstream.lastReq.Header.Get("Authorization"))
|
||||
require.Equal(t, "test-account", upstream.lastReq.Header.Get("Chatgpt-Account-Id"))
|
||||
require.Equal(t, "/backend-api/codex/images/generations", upstream.lastReq.URL.Path)
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,7 @@ func TestOpenAIImagesJSONKeepaliveWriter_NilGuards(t *testing.T) {
|
||||
// 可重试上游错误必须仍转换为 UpstreamFailoverError(而非裸错误吞掉换号)。
|
||||
func TestOpenAIImagesJSONKeepalive_HeartbeatBeforeForwardStillFailsOver(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`)
|
||||
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","response_format":"b64_json"}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -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"
|
||||
|
||||
@@ -791,7 +791,7 @@ func findOpenAIImageTestSSEEvent(events []openAIImageTestSSEEvent, name string)
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuthPassesNAndReturnsAllImages(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024","quality":"high","n":3}`)
|
||||
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","size":"1024x1024","quality":"high","n":3}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -833,8 +833,8 @@ func TestOpenAIGatewayServiceForwardImages_OAuthPassesNAndReturnsAllImages(t *te
|
||||
result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "gpt-image-2", result.Model)
|
||||
require.Equal(t, "gpt-image-2", result.UpstreamModel)
|
||||
require.Equal(t, "gpt-image-1", result.Model)
|
||||
require.Equal(t, "gpt-image-1", result.UpstreamModel)
|
||||
require.Equal(t, 3, result.ImageCount)
|
||||
require.Equal(t, 46, result.Usage.InputTokens)
|
||||
require.Equal(t, 2459, result.Usage.OutputTokens)
|
||||
@@ -853,14 +853,14 @@ func TestOpenAIGatewayServiceForwardImages_OAuthPassesNAndReturnsAllImages(t *te
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())
|
||||
require.Equal(t, "image_generation", gjson.GetBytes(upstream.lastBody, "tools.0.type").String())
|
||||
require.Equal(t, "generate", gjson.GetBytes(upstream.lastBody, "tools.0.action").String())
|
||||
require.Equal(t, "gpt-image-2", gjson.GetBytes(upstream.lastBody, "tools.0.model").String())
|
||||
require.Equal(t, "gpt-image-1", gjson.GetBytes(upstream.lastBody, "tools.0.model").String())
|
||||
require.Equal(t, "1024x1024", gjson.GetBytes(upstream.lastBody, "tools.0.size").String())
|
||||
require.Equal(t, "high", gjson.GetBytes(upstream.lastBody, "tools.0.quality").String())
|
||||
require.Equal(t, int64(3), gjson.GetBytes(upstream.lastBody, "tools.0.n").Int())
|
||||
require.Equal(t, "draw a cat", gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String())
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "gpt-image-2", gjson.Get(rec.Body.String(), "model").String())
|
||||
require.Equal(t, "gpt-image-1", gjson.Get(rec.Body.String(), "model").String())
|
||||
require.Len(t, gjson.Get(rec.Body.String(), "data").Array(), 3)
|
||||
require.Equal(t, "aW1hZ2UtMQ==", gjson.Get(rec.Body.String(), "data.0.b64_json").String())
|
||||
require.Equal(t, "aW1hZ2UtMg==", gjson.Get(rec.Body.String(), "data.1.b64_json").String())
|
||||
@@ -946,7 +946,7 @@ func TestBoundedJSONNonNegativeInt(t *testing.T) {
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuthUpstreamHTTPErrorSurfacesRealError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`)
|
||||
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","response_format":"b64_json"}`)
|
||||
|
||||
// The non-failover upstream error path is shared by /generations and /edits;
|
||||
// use /generations here so the request parses without an uploaded image.
|
||||
@@ -1004,7 +1004,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthUpstreamHTTPErrorSurfacesRealErr
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuthNonStreamModerationBlockedReturnsClientError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw blocked image","response_format":"b64_json"}`)
|
||||
body := []byte(`{"model":"gpt-image-1","prompt":"draw blocked image","response_format":"b64_json"}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -1057,7 +1057,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthNonStreamModerationBlockedReturn
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuthNonStreamServerErrorReturnsFailoverBeforeFlush(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`)
|
||||
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","response_format":"b64_json"}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -1114,7 +1114,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthNonStreamServerErrorReturnsFailo
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuth429CarriesSameAccountRetryWindow(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`)
|
||||
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","response_format":"b64_json"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -1247,7 +1247,7 @@ func TestShouldClassifyOpenAIUpstreamStreamReadErrorTransportStrings(t *testing.
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuthStreamServerErrorAfterFlushDoesNotFailover(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`)
|
||||
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -1657,7 +1657,7 @@ func TestOpenAIGatewayServiceForwardImages_APIKeyEditUsesConfiguredV1BaseURL(t *
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuthStreamingTransformsEvents(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"url"}`)
|
||||
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","stream":true,"response_format":"url"}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -1677,9 +1677,9 @@ func TestOpenAIGatewayServiceForwardImages_OAuthStreamingTransformsEvents(t *tes
|
||||
"X-Request-Id": []string{"req_img_stream"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000001,\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-2\",\"background\":\"auto\",\"output_format\":\"png\",\"quality\":\"high\",\"size\":\"1024x1024\"}]}}\n\n" +
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000001,\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-1\",\"background\":\"auto\",\"output_format\":\"png\",\"quality\":\"high\",\"size\":\"1024x1024\"}]}}\n\n" +
|
||||
"data: {\"type\":\"response.image_generation_call.partial_image\",\"partial_image_b64\":\"cGFydGlhbA==\",\"partial_image_index\":0,\"output_format\":\"png\",\"background\":\"auto\"}\n\n" +
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000001,\"usage\":{\"input_tokens\":5,\"output_tokens\":9,\"output_tokens_details\":{\"image_tokens\":4}},\"tool_usage\":{\"image_gen\":{\"input_tokens\":46,\"output_tokens\":2459,\"output_tokens_details\":{\"image_tokens\":2459},\"images\":1}},\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-2\",\"background\":\"auto\",\"output_format\":\"png\",\"quality\":\"high\",\"size\":\"1024x1024\"}],\"output\":[{\"type\":\"image_generation_call\",\"result\":\"ZmluYWw=\",\"output_format\":\"png\"}]}}\n\n" +
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000001,\"usage\":{\"input_tokens\":5,\"output_tokens\":9,\"output_tokens_details\":{\"image_tokens\":4}},\"tool_usage\":{\"image_gen\":{\"input_tokens\":46,\"output_tokens\":2459,\"output_tokens_details\":{\"image_tokens\":2459},\"images\":1}},\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-1\",\"background\":\"auto\",\"output_format\":\"png\",\"quality\":\"high\",\"size\":\"1024x1024\"}],\"output\":[{\"type\":\"image_generation_call\",\"result\":\"ZmluYWw=\",\"output_format\":\"png\"}]}}\n\n" +
|
||||
"data: [DONE]\n\n",
|
||||
)),
|
||||
},
|
||||
@@ -1709,7 +1709,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthStreamingTransformsEvents(t *tes
|
||||
require.Equal(t, int64(1710000001), gjson.Get(partial.Data, "created_at").Int())
|
||||
require.Equal(t, "cGFydGlhbA==", gjson.Get(partial.Data, "b64_json").String())
|
||||
require.Equal(t, "data:image/png;base64,cGFydGlhbA==", gjson.Get(partial.Data, "url").String())
|
||||
require.Equal(t, "gpt-image-2", gjson.Get(partial.Data, "model").String())
|
||||
require.Equal(t, "gpt-image-1", gjson.Get(partial.Data, "model").String())
|
||||
require.Equal(t, "png", gjson.Get(partial.Data, "output_format").String())
|
||||
require.Equal(t, "high", gjson.Get(partial.Data, "quality").String())
|
||||
require.Equal(t, "1024x1024", gjson.Get(partial.Data, "size").String())
|
||||
@@ -1721,7 +1721,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthStreamingTransformsEvents(t *tes
|
||||
require.Equal(t, int64(1710000001), gjson.Get(completed.Data, "created_at").Int())
|
||||
require.Equal(t, "ZmluYWw=", gjson.Get(completed.Data, "b64_json").String())
|
||||
require.Equal(t, "data:image/png;base64,ZmluYWw=", gjson.Get(completed.Data, "url").String())
|
||||
require.Equal(t, "gpt-image-2", gjson.Get(completed.Data, "model").String())
|
||||
require.Equal(t, "gpt-image-1", gjson.Get(completed.Data, "model").String())
|
||||
require.Equal(t, "png", gjson.Get(completed.Data, "output_format").String())
|
||||
require.Equal(t, "high", gjson.Get(completed.Data, "quality").String())
|
||||
require.Equal(t, "1024x1024", gjson.Get(completed.Data, "size").String())
|
||||
@@ -1790,7 +1790,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthEditsMultipartUsesResponsesAPI(t
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
require.NoError(t, writer.WriteField("model", "gpt-image-2"))
|
||||
require.NoError(t, writer.WriteField("model", "gpt-image-1"))
|
||||
require.NoError(t, writer.WriteField("prompt", "replace background with aurora"))
|
||||
require.NoError(t, writer.WriteField("input_fidelity", "high"))
|
||||
require.NoError(t, writer.WriteField("output_format", "webp"))
|
||||
@@ -1854,7 +1854,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthEditsMultipartUsesResponsesAPI(t
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, 1, result.ImageCount)
|
||||
require.Equal(t, "gpt-image-2", gjson.GetBytes(upstream.lastBody, "tools.0.model").String())
|
||||
require.Equal(t, "gpt-image-1", gjson.GetBytes(upstream.lastBody, "tools.0.model").String())
|
||||
require.Equal(t, "edit", gjson.GetBytes(upstream.lastBody, "tools.0.action").String())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "tools.0.input_fidelity").Exists())
|
||||
require.Equal(t, "webp", gjson.GetBytes(upstream.lastBody, "tools.0.output_format").String())
|
||||
@@ -1868,7 +1868,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthEditsMultipartUsesResponsesAPI(t
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuthEditsStreamingTransformsEvents(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{
|
||||
"model":"gpt-image-2",
|
||||
"model":"gpt-image-1",
|
||||
"prompt":"replace background with aurora",
|
||||
"images":[{"image_url":"https://example.com/source.png"}],
|
||||
"mask":{"image_url":"https://example.com/mask.png"},
|
||||
@@ -1893,9 +1893,9 @@ func TestOpenAIGatewayServiceForwardImages_OAuthEditsStreamingTransformsEvents(t
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000003,\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-2\",\"background\":\"transparent\",\"output_format\":\"webp\",\"quality\":\"high\",\"size\":\"1024x1024\"}]}}\n\n" +
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000003,\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-1\",\"background\":\"transparent\",\"output_format\":\"webp\",\"quality\":\"high\",\"size\":\"1024x1024\"}]}}\n\n" +
|
||||
"data: {\"type\":\"response.image_generation_call.partial_image\",\"partial_image_b64\":\"cGFydGlhbA==\",\"partial_image_index\":0,\"output_format\":\"webp\",\"background\":\"transparent\"}\n\n" +
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000003,\"usage\":{\"input_tokens\":7,\"output_tokens\":10,\"output_tokens_details\":{\"image_tokens\":5}},\"tool_usage\":{\"image_gen\":{\"images\":1}},\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-2\",\"background\":\"transparent\",\"output_format\":\"webp\",\"quality\":\"high\",\"size\":\"1024x1024\"}],\"output\":[{\"type\":\"image_generation_call\",\"result\":\"ZWRpdGVk\",\"revised_prompt\":\"replace background with aurora\",\"output_format\":\"webp\"}]}}\n\n" +
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000003,\"usage\":{\"input_tokens\":7,\"output_tokens\":10,\"output_tokens_details\":{\"image_tokens\":5}},\"tool_usage\":{\"image_gen\":{\"images\":1}},\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-1\",\"background\":\"transparent\",\"output_format\":\"webp\",\"quality\":\"high\",\"size\":\"1024x1024\"}],\"output\":[{\"type\":\"image_generation_call\",\"result\":\"ZWRpdGVk\",\"revised_prompt\":\"replace background with aurora\",\"output_format\":\"webp\"}]}}\n\n" +
|
||||
"data: [DONE]\n\n",
|
||||
)),
|
||||
},
|
||||
@@ -1926,7 +1926,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthEditsStreamingTransformsEvents(t
|
||||
require.Equal(t, int64(1710000003), gjson.Get(partial.Data, "created_at").Int())
|
||||
require.Equal(t, "cGFydGlhbA==", gjson.Get(partial.Data, "b64_json").String())
|
||||
require.Equal(t, "data:image/webp;base64,cGFydGlhbA==", gjson.Get(partial.Data, "url").String())
|
||||
require.Equal(t, "gpt-image-2", gjson.Get(partial.Data, "model").String())
|
||||
require.Equal(t, "gpt-image-1", gjson.Get(partial.Data, "model").String())
|
||||
require.Equal(t, "webp", gjson.Get(partial.Data, "output_format").String())
|
||||
require.Equal(t, "high", gjson.Get(partial.Data, "quality").String())
|
||||
require.Equal(t, "1024x1024", gjson.Get(partial.Data, "size").String())
|
||||
@@ -1938,7 +1938,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthEditsStreamingTransformsEvents(t
|
||||
require.Equal(t, int64(1710000003), gjson.Get(completed.Data, "created_at").Int())
|
||||
require.Equal(t, "ZWRpdGVk", gjson.Get(completed.Data, "b64_json").String())
|
||||
require.Equal(t, "data:image/webp;base64,ZWRpdGVk", gjson.Get(completed.Data, "url").String())
|
||||
require.Equal(t, "gpt-image-2", gjson.Get(completed.Data, "model").String())
|
||||
require.Equal(t, "gpt-image-1", gjson.Get(completed.Data, "model").String())
|
||||
require.Equal(t, "webp", gjson.Get(completed.Data, "output_format").String())
|
||||
require.Equal(t, "high", gjson.Get(completed.Data, "quality").String())
|
||||
require.Equal(t, "1024x1024", gjson.Get(completed.Data, "size").String())
|
||||
@@ -2064,7 +2064,7 @@ func TestCollectOpenAIImagesFromResponsesBody_MultilineSSE(t *testing.T) {
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuthStreamingHandlesOutputItemDoneFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"url"}`)
|
||||
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","stream":true,"response_format":"url"}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -2114,14 +2114,14 @@ func TestOpenAIGatewayServiceForwardImages_OAuthStreamingHandlesOutputItemDoneFa
|
||||
require.Equal(t, int64(1710000005), gjson.Get(completed.Data, "created_at").Int())
|
||||
require.Equal(t, "ZmluYWw=", gjson.Get(completed.Data, "b64_json").String())
|
||||
require.Equal(t, "data:image/png;base64,ZmluYWw=", gjson.Get(completed.Data, "url").String())
|
||||
require.Equal(t, "gpt-image-2", gjson.Get(completed.Data, "model").String())
|
||||
require.Equal(t, "gpt-image-1", gjson.Get(completed.Data, "model").String())
|
||||
require.JSONEq(t, `{"images":1}`, gjson.Get(completed.Data, "usage").Raw)
|
||||
require.NotContains(t, rec.Body.String(), "event: error")
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuthStreamingHandlesMultilineSSE(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`)
|
||||
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -2176,7 +2176,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthStreamingHandlesMultilineSSE(t *
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuthStreamingDrainsAfterClientDisconnect(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"url"}`)
|
||||
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","stream":true,"response_format":"url"}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestOpenAIGatewayServiceGetAccessTokenSetupToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAISetupTokenImagesUsesOAuthResponsesPath(t *testing.T) {
|
||||
func TestOpenAISetupTokenImagesUsesOAuthDirectPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
|
||||
@@ -100,7 +100,7 @@ func TestOpenAISetupTokenImagesUsesOAuthResponsesPath(t *testing.T) {
|
||||
require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode)
|
||||
require.True(t, failoverErr.RetryableOnSameAccount)
|
||||
require.False(t, failoverErr.SameAccountRetryDeadline.IsZero())
|
||||
require.Contains(t, upstream.lastReq.URL.String(), "/backend-api/codex/responses")
|
||||
require.Contains(t, upstream.lastReq.URL.String(), "/backend-api/codex/images/generations")
|
||||
}
|
||||
|
||||
func TestOpenAISetupTokenWSCompatibility(t *testing.T) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user