openai_images_model_test.go 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/require"
  13. "github.com/tidwall/gjson"
  14. )
  15. func TestOpenAIImagesResponsesDriverAndImageModels(t *testing.T) {
  16. for _, override := range []string{"", " ", " gpt-5.6-sol "} {
  17. t.Run(fmt.Sprintf("override=%q", override), func(t *testing.T) {
  18. t.Setenv("SUB2API_IMAGES_MAIN_MODEL", override)
  19. driver := strings.TrimSpace(override)
  20. if driver == "" {
  21. driver = "gpt-5.6-luna"
  22. }
  23. for _, model := range []string{"gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2.5-flare-2026-09-08"} {
  24. for _, quality := range []string{"xhigh", "max", "auto"} {
  25. for _, endpoint := range []string{openAIImagesGenerationsEndpoint, openAIImagesEditsEndpoint} {
  26. parsed := &OpenAIImagesRequest{Endpoint: endpoint, Model: model, Prompt: "draw a red cup", Quality: quality, Size: "1536x864", Background: "transparent", OutputFormat: "png", N: 1}
  27. if parsed.IsEdits() {
  28. parsed.InputImageURLs = []string{"https://example.com/input.png"}
  29. }
  30. body, err := buildOpenAIImagesResponsesRequest(parsed, model)
  31. require.NoError(t, err)
  32. require.Equal(t, driver, gjson.GetBytes(body, "model").String())
  33. require.Equal(t, model, gjson.GetBytes(body, "tools.0.model").String())
  34. require.Equal(t, quality, gjson.GetBytes(body, "tools.0.quality").String())
  35. require.Equal(t, "1536x864", gjson.GetBytes(body, "tools.0.size").String())
  36. require.Equal(t, "transparent", gjson.GetBytes(body, "tools.0.background").String())
  37. if parsed.IsEdits() {
  38. require.Equal(t, "edit", gjson.GetBytes(body, "tools.0.action").String())
  39. require.Equal(t, parsed.InputImageURLs[0], gjson.GetBytes(body, "input.0.content.1.image_url").String())
  40. }
  41. }
  42. }
  43. req := map[string]any{"model": model, "input": "draw a red cup"}
  44. require.True(t, normalizeOpenAIResponsesImageOnlyModel(req))
  45. require.Equal(t, driver, req["model"])
  46. require.Equal(t, model, req["tools"].([]any)[0].(map[string]any)["model"])
  47. require.False(t, normalizeOpenAIResponsesImageOnlyModel(req), "a valid driver must not be overwritten")
  48. }
  49. req := map[string]any{"model": "gpt-6-astra", "tools": []any{map[string]any{"type": "image_generation", "model": "gpt-image-2.5-sunburst"}}}
  50. require.False(t, normalizeOpenAIResponsesImageOnlyModel(req))
  51. require.Equal(t, "gpt-6-astra", req["model"])
  52. })
  53. }
  54. }
  55. func TestOpenAIImagesRejectedDriverDoesNotCoolImageModel(t *testing.T) {
  56. gin.SetMode(gin.TestMode)
  57. t.Setenv("SUB2API_IMAGES_MAIN_MODEL", "gpt-5.4-mini")
  58. for _, rejected := range []string{"gpt-5.4-mini", "gpt-image-2.5-flare"} {
  59. t.Run(rejected, func(t *testing.T) {
  60. repo := &modelNotFoundAccountRepoStub{}
  61. svc := &OpenAIGatewayService{rateLimitService: &RateLimitService{accountRepo: repo}}
  62. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  63. c.Request = httptest.NewRequest(http.MethodPost, openAIImagesGenerationsEndpoint, nil)
  64. body := fmt.Sprintf(`{"error":{"message":"The '%s' model is not supported when using Codex with a ChatGPT account.","type":"invalid_request_error"}}`, rejected)
  65. resp := &http.Response{StatusCode: 400, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body))}
  66. _, err := svc.handleOpenAIImagesErrorResponse(WithOpenAIImagesEndpoint(context.Background()), resp, c, openAICodexPlanGatedOAuthAccount(), "gpt-image-2.5-flare")
  67. require.Error(t, err)
  68. if rejected == "gpt-5.4-mini" {
  69. var upstreamErr *OpenAIImagesUpstreamError
  70. require.ErrorAs(t, err, &upstreamErr)
  71. require.Equal(t, 400, upstreamErr.StatusCode)
  72. require.Contains(t, upstreamErr.Message, rejected)
  73. require.Empty(t, repo.modelRateLimitCalls)
  74. require.Zero(t, repo.tempCalls)
  75. } else {
  76. require.Len(t, repo.modelRateLimitCalls, 1, "actual image-model rejection still needs bounded failover")
  77. }
  78. })
  79. }
  80. }
  81. func TestGPTImage25PricingDoesNotUseLegacyImageRates(t *testing.T) {
  82. 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"} {
  83. svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{"gpt-image-2": {InputCostPerToken: 2.5e-6, OutputCostPerImageToken: 15e-6}}}
  84. p := svc.GetModelPricing(model)
  85. require.NotNil(t, p)
  86. require.Equal(t, 5e-6, p.InputCostPerToken)
  87. require.Equal(t, 8e-6, p.InputCostPerImageToken)
  88. require.Equal(t, 30e-6, p.OutputCostPerImageToken)
  89. require.Equal(t, 1.25e-6, p.CacheReadInputTokenCost)
  90. require.Zero(t, p.OutputCostPerToken)
  91. custom := &LiteLLMModelPricing{InputCostPerToken: 7e-6}
  92. svc.pricingData[model] = custom
  93. require.Same(t, custom, svc.GetModelPricing(model), "explicit pricing must win")
  94. }
  95. }
  96. func TestGPTImage25AccountModelPermissions(t *testing.T) {
  97. for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
  98. for _, model := range []string{"gpt-image-2.5-flare", "gpt-image-2.5-sunburst"} {
  99. for _, mapping := range []map[string]any{nil, {model: model}} {
  100. account := &Account{Platform: PlatformOpenAI, Type: accountType, Credentials: map[string]any{"model_mapping": mapping}}
  101. require.True(t, account.IsModelSupported(model))
  102. require.Equal(t, model, account.GetMappedModel(model))
  103. }
  104. restricted := &Account{Platform: PlatformOpenAI, Type: accountType, Credentials: map[string]any{"model_mapping": map[string]any{"gpt-image-2": "gpt-image-2"}}}
  105. require.False(t, restricted.IsModelSupported(model), "an explicit administrator allowlist must remain restricted")
  106. }
  107. }
  108. }
  109. func TestGPTImage25UsagePreservesImageInputTokens(t *testing.T) {
  110. svc := &OpenAIGatewayService{}
  111. var usage OpenAIUsage
  112. svc.parseOpenAIImagesSSEUsageBytes([]byte(`{"type":"response.completed","response":{"tool_usage":{"image_gen":{"input_tokens":1550,"input_tokens_details":{"image_tokens":1521,"text_tokens":29},"output_tokens":515,"output_tokens_details":{"image_tokens":515}}}}}`), &usage)
  113. require.Equal(t, 1550, usage.InputTokens)
  114. require.Equal(t, 1521, usage.ImageInputTokens)
  115. require.Equal(t, 515, usage.ImageOutputTokens)
  116. }