mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
Merge pull request #6874 from hansnow/fix/gpt-image-2-5-oauth
fix(openai): 支持 Image 2.5 并修复 OAuth 生图主控模型
This commit is contained in:
@@ -706,6 +706,14 @@ go generate ./cmd/server
|
||||
|
||||
---
|
||||
|
||||
## OpenAI 图片模型
|
||||
|
||||
支持 `gpt-image-2.5-flare`、`gpt-image-2.5-sunburst` 及其 `2026-09-08` 日期快照,可通过 `/v1/images/generations`、`/v1/images/edits` 调用。`quality` 支持 `xhigh`、`max`、`auto`,合法自定义尺寸和图片 usage 明细保持透传。
|
||||
|
||||
OAuth / Setup Token 图片请求使用 Responses 主控模型调用 `image_generation` 工具,默认主控为 `gpt-5.6-luna`。可设置 `SUB2API_IMAGES_MAIN_MODEL` 切换为账号支持的文本模型;Docker Compose 用户修改 `.env` 后执行 `docker compose up -d` 重建容器。该配置不会替换所选图片模型,也不会覆盖 `/v1/responses` 请求中已经提供的文本主控模型。
|
||||
|
||||
升级后,无模型限制的账号自动支持新模型。已有显式账号映射或分组白名单需要加入两个 2.5 模型(日期快照按需加入);升级不会自动扩大管理员设置的模型权限。新模型内置价格包含官方文本输入、图片输入和图片输出 token 费率,远端价格表尚未更新时使用内置 2.5 价格;实际按次或按 token 计费仍由既有分组/渠道配置决定。
|
||||
|
||||
## 简易模式
|
||||
|
||||
简易模式适合个人开发者或内部团队快速使用,不依赖完整 SaaS 功能。
|
||||
|
||||
@@ -33,6 +33,8 @@ var DefaultModels = []Model{
|
||||
{ID: "gpt-image-1", Object: "model", Created: 1733875200, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 1"},
|
||||
{ID: "gpt-image-1.5", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 1.5"},
|
||||
{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"},
|
||||
}
|
||||
|
||||
// DefaultModelIDs returns the default model ID list
|
||||
|
||||
@@ -27,3 +27,8 @@ func TestDefaultModelsPreferConcreteGPT56SolForAccountTests(t *testing.T) {
|
||||
require.NotEmpty(t, DefaultModels)
|
||||
require.Equal(t, "gpt-5.6-sol", DefaultModels[0].ID)
|
||||
}
|
||||
|
||||
func TestDefaultModelsIncludeGPTImage25(t *testing.T) {
|
||||
require.Contains(t, DefaultModelIDs(), "gpt-image-2.5-flare")
|
||||
require.Contains(t, DefaultModelIDs(), "gpt-image-2.5-sunburst")
|
||||
}
|
||||
|
||||
@@ -19,12 +19,19 @@ func TestFetchOpenAIAccountModelsOAuthPopulatesPickerFields(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
models, err := svc.FetchOpenAIAccountModels(ctx, account)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, models, 2)
|
||||
require.Greater(t, len(models), 2)
|
||||
for i, id := range []string{"new-oauth-model", "gpt-6-astra"} {
|
||||
require.Equal(t, id, models[i].ID)
|
||||
require.Equal(t, id, models[i].DisplayName)
|
||||
require.Equal(t, "model", models[i].Type)
|
||||
}
|
||||
ids := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
require.Contains(t, ids, "gpt-image-2.5-flare")
|
||||
require.Contains(t, ids, "gpt-image-2.5-sunburst")
|
||||
|
||||
after, err := gateway.FetchOpenAIModelsList(ctx, account)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, before.Body, after.Body, "picker fields must not change the shared catalog")
|
||||
@@ -62,3 +69,18 @@ func TestFetchOpenAIAccountModelsPreservesEmptyCatalog(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, models, "an empty upstream catalog must not become a static model list")
|
||||
}
|
||||
|
||||
func TestFetchOpenAIAccountModelsOAuthRespectsImageAllowlist(t *testing.T) {
|
||||
newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"gpt-6-astra"}]}`)
|
||||
svc := &AccountTestService{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)
|
||||
ids := []string{}
|
||||
for _, model := range models {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
require.Contains(t, ids, "gpt-image-2.5-flare")
|
||||
require.NotContains(t, ids, "gpt-image-2.5-sunburst")
|
||||
}
|
||||
|
||||
@@ -203,6 +203,26 @@ func (s *AccountTestService) FetchOpenAIAccountModels(ctx context.Context, accou
|
||||
model.Type = "model"
|
||||
}
|
||||
}
|
||||
// Codex discovery lists Responses drivers, not image_generation tool models.
|
||||
// Add locally supported image choices only to the OAuth test picker; keep the
|
||||
// shared upstream catalog and API-key discovery authoritative.
|
||||
if account != nil && account.IsOpenAIOAuthLike() {
|
||||
seen := make(map[string]bool, len(payload.Data))
|
||||
for _, model := range payload.Data {
|
||||
seen[model.ID] = true
|
||||
}
|
||||
for _, model := range openai.DefaultModels {
|
||||
if IsGPTImageGenerationModel(model.ID) && account.IsModelSupported(model.ID) && !seen[model.ID] {
|
||||
payload.Data = append(payload.Data, model)
|
||||
seen[model.ID] = true
|
||||
}
|
||||
}
|
||||
for model := range account.GetModelMapping() {
|
||||
if IsGPTImageGenerationModel(model) && !strings.Contains(model, "*") && !seen[model] {
|
||||
payload.Data = append(payload.Data, openai.Model{ID: model, Object: "model", Type: "model", OwnedBy: "openai", DisplayName: model})
|
||||
}
|
||||
}
|
||||
}
|
||||
return payload.Data, nil
|
||||
}
|
||||
|
||||
@@ -3031,6 +3051,8 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
|
||||
}
|
||||
applyOpenAIImagesDefaults(parsed)
|
||||
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Responses driver: %s; image model: %s\n", openAIImagesResponsesMainModelValue(), parsed.Model)})
|
||||
|
||||
responsesBody, err := buildOpenAIImagesResponsesRequest(parsed, parsed.Model)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to build image request: %s", err.Error()))
|
||||
@@ -3104,6 +3126,12 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to parse image response: %s", err.Error()))
|
||||
}
|
||||
if len(results) == 0 {
|
||||
if upstreamErr := extractOpenAIImagesUpstreamError(body); upstreamErr != nil {
|
||||
return s.sendErrorAndEnd(c, upstreamErr.clientMessage())
|
||||
}
|
||||
if textErr := openAIImagesTextFallbackError(body); textErr != nil {
|
||||
return s.sendErrorAndEnd(c, textErr.clientMessage())
|
||||
}
|
||||
return s.sendErrorAndEnd(c, "No images returned from responses API")
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ 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)
|
||||
@@ -43,9 +45,11 @@ func TestAccountTestService_OpenAIImageOAuthHandlesOutputItemDoneFallback(t *tes
|
||||
},
|
||||
}
|
||||
|
||||
err := svc.testOpenAIImageOAuth(c, context.Background(), account, "gpt-image-2", "draw a cat")
|
||||
err := svc.testOpenAIImageOAuth(c, context.Background(), account, "gpt-image-2.5-flare", "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=")
|
||||
@@ -91,3 +95,18 @@ 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")
|
||||
}
|
||||
|
||||
@@ -1216,10 +1216,11 @@ func normalizeOpenAIResponsesImageOnlyModel(reqBody map[string]any) bool {
|
||||
reqBody["tool_choice"] = map[string]any{"type": "image_generation"}
|
||||
modified = true
|
||||
}
|
||||
if imageModel != openAIImagesResponsesMainModel {
|
||||
mainModel := openAIImagesResponsesMainModelValue()
|
||||
if imageModel != mainModel {
|
||||
modified = true
|
||||
}
|
||||
reqBody["model"] = openAIImagesResponsesMainModel
|
||||
reqBody["model"] = mainModel
|
||||
return modified
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
@@ -39,10 +40,20 @@ const (
|
||||
openAIImageBackendUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
openAIImageMaxDownloadBytes = 20 << 20 // 20MB per image download
|
||||
openAIImageMaxUploadPartSize = 20 << 20 // 20MB per multipart upload part
|
||||
openAIImagesResponsesMainModel = "gpt-5.4-mini"
|
||||
openAIImagesResponsesMainModel = "gpt-5.6-luna"
|
||||
openAIImagesVerbatimPromptInstructions = "When invoking the image_generation tool, use the user's image prompt verbatim. Do not rewrite, expand, summarize, embellish, translate, normalize punctuation, or add or remove visual details or constraints. Preserve the original language, wording, capitalization, quotes, and punctuation exactly."
|
||||
)
|
||||
|
||||
// openAIImagesResponsesMainModelValue selects the Responses driver independently
|
||||
// of the image_generation tool model. An environment override lets operators
|
||||
// recover from upstream model retirement without rebuilding the gateway.
|
||||
func openAIImagesResponsesMainModelValue() string {
|
||||
if model := strings.TrimSpace(os.Getenv("SUB2API_IMAGES_MAIN_MODEL")); model != "" {
|
||||
return model
|
||||
}
|
||||
return openAIImagesResponsesMainModel
|
||||
}
|
||||
|
||||
type OpenAIImagesCapability string
|
||||
|
||||
const (
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestOpenAIImagesResponsesDriverAndImageModels(t *testing.T) {
|
||||
for _, override := range []string{"", " ", " gpt-5.6-sol "} {
|
||||
t.Run(fmt.Sprintf("override=%q", override), func(t *testing.T) {
|
||||
t.Setenv("SUB2API_IMAGES_MAIN_MODEL", override)
|
||||
driver := strings.TrimSpace(override)
|
||||
if driver == "" {
|
||||
driver = "gpt-5.6-luna"
|
||||
}
|
||||
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"} {
|
||||
for _, quality := range []string{"xhigh", "max", "auto"} {
|
||||
for _, endpoint := range []string{openAIImagesGenerationsEndpoint, openAIImagesEditsEndpoint} {
|
||||
parsed := &OpenAIImagesRequest{Endpoint: endpoint, Model: model, Prompt: "draw a red cup", Quality: quality, Size: "1536x864", Background: "transparent", OutputFormat: "png", N: 1}
|
||||
if parsed.IsEdits() {
|
||||
parsed.InputImageURLs = []string{"https://example.com/input.png"}
|
||||
}
|
||||
body, err := buildOpenAIImagesResponsesRequest(parsed, model)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, driver, gjson.GetBytes(body, "model").String())
|
||||
require.Equal(t, model, gjson.GetBytes(body, "tools.0.model").String())
|
||||
require.Equal(t, quality, gjson.GetBytes(body, "tools.0.quality").String())
|
||||
require.Equal(t, "1536x864", gjson.GetBytes(body, "tools.0.size").String())
|
||||
require.Equal(t, "transparent", gjson.GetBytes(body, "tools.0.background").String())
|
||||
if parsed.IsEdits() {
|
||||
require.Equal(t, "edit", gjson.GetBytes(body, "tools.0.action").String())
|
||||
require.Equal(t, parsed.InputImageURLs[0], gjson.GetBytes(body, "input.0.content.1.image_url").String())
|
||||
}
|
||||
}
|
||||
}
|
||||
req := map[string]any{"model": model, "input": "draw a red cup"}
|
||||
require.True(t, normalizeOpenAIResponsesImageOnlyModel(req))
|
||||
require.Equal(t, driver, req["model"])
|
||||
require.Equal(t, model, req["tools"].([]any)[0].(map[string]any)["model"])
|
||||
require.False(t, normalizeOpenAIResponsesImageOnlyModel(req), "a valid driver must not be overwritten")
|
||||
}
|
||||
req := map[string]any{"model": "gpt-6-astra", "tools": []any{map[string]any{"type": "image_generation", "model": "gpt-image-2.5-sunburst"}}}
|
||||
require.False(t, normalizeOpenAIResponsesImageOnlyModel(req))
|
||||
require.Equal(t, "gpt-6-astra", req["model"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIImagesRejectedDriverDoesNotCoolImageModel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("SUB2API_IMAGES_MAIN_MODEL", "gpt-5.4-mini")
|
||||
for _, rejected := range []string{"gpt-5.4-mini", "gpt-image-2.5-flare"} {
|
||||
t.Run(rejected, func(t *testing.T) {
|
||||
repo := &modelNotFoundAccountRepoStub{}
|
||||
svc := &OpenAIGatewayService{rateLimitService: &RateLimitService{accountRepo: repo}}
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, openAIImagesGenerationsEndpoint, nil)
|
||||
body := fmt.Sprintf(`{"error":{"message":"The '%s' model is not supported when using Codex with a ChatGPT account.","type":"invalid_request_error"}}`, rejected)
|
||||
resp := &http.Response{StatusCode: 400, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body))}
|
||||
_, err := svc.handleOpenAIImagesErrorResponse(WithOpenAIImagesEndpoint(context.Background()), resp, c, openAICodexPlanGatedOAuthAccount(), "gpt-image-2.5-flare")
|
||||
require.Error(t, err)
|
||||
if rejected == "gpt-5.4-mini" {
|
||||
var upstreamErr *OpenAIImagesUpstreamError
|
||||
require.ErrorAs(t, err, &upstreamErr)
|
||||
require.Equal(t, 400, upstreamErr.StatusCode)
|
||||
require.Contains(t, upstreamErr.Message, rejected)
|
||||
require.Empty(t, repo.modelRateLimitCalls)
|
||||
require.Zero(t, repo.tempCalls)
|
||||
} else {
|
||||
require.Len(t, repo.modelRateLimitCalls, 1, "actual image-model rejection still needs bounded failover")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPTImage25PricingDoesNotUseLegacyImageRates(t *testing.T) {
|
||||
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"} {
|
||||
svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{"gpt-image-2": {InputCostPerToken: 2.5e-6, OutputCostPerImageToken: 15e-6}}}
|
||||
p := svc.GetModelPricing(model)
|
||||
require.NotNil(t, p)
|
||||
require.Equal(t, 5e-6, p.InputCostPerToken)
|
||||
require.Equal(t, 8e-6, p.InputCostPerImageToken)
|
||||
require.Equal(t, 30e-6, p.OutputCostPerImageToken)
|
||||
require.Equal(t, 1.25e-6, p.CacheReadInputTokenCost)
|
||||
require.Zero(t, p.OutputCostPerToken)
|
||||
custom := &LiteLLMModelPricing{InputCostPerToken: 7e-6}
|
||||
svc.pricingData[model] = custom
|
||||
require.Same(t, custom, svc.GetModelPricing(model), "explicit pricing must win")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPTImage25AccountModelPermissions(t *testing.T) {
|
||||
for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
|
||||
for _, model := range []string{"gpt-image-2.5-flare", "gpt-image-2.5-sunburst"} {
|
||||
for _, mapping := range []map[string]any{nil, {model: model}} {
|
||||
account := &Account{Platform: PlatformOpenAI, Type: accountType, Credentials: map[string]any{"model_mapping": mapping}}
|
||||
require.True(t, account.IsModelSupported(model))
|
||||
require.Equal(t, model, account.GetMappedModel(model))
|
||||
}
|
||||
restricted := &Account{Platform: PlatformOpenAI, Type: accountType, Credentials: map[string]any{"model_mapping": map[string]any{"gpt-image-2": "gpt-image-2"}}}
|
||||
require.False(t, restricted.IsModelSupported(model), "an explicit administrator allowlist must remain restricted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPTImage25UsagePreservesImageInputTokens(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
var usage OpenAIUsage
|
||||
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)
|
||||
require.Equal(t, 1550, usage.InputTokens)
|
||||
require.Equal(t, 1521, usage.ImageInputTokens)
|
||||
require.Equal(t, 515, usage.ImageOutputTokens)
|
||||
}
|
||||
@@ -382,7 +382,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", openAIImagesResponsesMainModel)
|
||||
req, _ = sjson.SetBytes(req, "model", openAIImagesResponsesMainModelValue())
|
||||
req, _ = sjson.SetBytes(req, "instructions", openAIImagesVerbatimPromptInstructions)
|
||||
|
||||
input := []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`)
|
||||
@@ -978,6 +978,17 @@ 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()+"'") {
|
||||
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 {
|
||||
@@ -1182,8 +1193,13 @@ func openAIImagesToolUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) {
|
||||
if !inputOK || !outputOK || !imageOutputOK {
|
||||
return OpenAIUsage{}, false
|
||||
}
|
||||
imageInputTokens, _ := boundedJSONNonNegativeInt(value.Get("input_tokens_details.image_tokens"))
|
||||
if imageInputTokens > inputTokens {
|
||||
imageInputTokens = inputTokens
|
||||
}
|
||||
return OpenAIUsage{
|
||||
InputTokens: inputTokens,
|
||||
ImageInputTokens: imageInputTokens,
|
||||
OutputTokens: outputTokens,
|
||||
ImageOutputTokens: imageOutputTokens,
|
||||
}, true
|
||||
|
||||
@@ -36,7 +36,18 @@ var (
|
||||
// (cache_creation_input_token_cost_above_200k_tokens、cache_read_input_token_cost_above_272k_tokens_priority、
|
||||
// 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]+)?)$`)
|
||||
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,
|
||||
}
|
||||
openAIGPT54FallbackPricing = &LiteLLMModelPricing{
|
||||
InputCostPerToken: 2.5e-06, // $2.5 per MTok
|
||||
OutputCostPerToken: 1.5e-05, // $15 per MTok
|
||||
@@ -1506,6 +1517,13 @@ 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) {
|
||||
for _, candidate := range []string{"gpt-image-2", "gpt-image-1.5", "gpt-image-1"} {
|
||||
if pricing, ok := s.pricingData[candidate]; ok {
|
||||
|
||||
@@ -6324,5 +6324,69 @@
|
||||
"supports_tool_choice": true,
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +288,10 @@ GATEWAY_FORCE_CODEX_CLI=false
|
||||
# OpenAI /responses/compact 上游模型(默认 gpt-5.4)。
|
||||
# 当 compact 端点暂未支持更新模型时,可通过这里降级规避失败。
|
||||
GATEWAY_OPENAI_COMPACT_MODEL=gpt-5.4
|
||||
|
||||
# OAuth Images /responses 主控模型,与 image_generation.model 独立。
|
||||
# 上游下线主控模型时可修改此项并重建/重启容器,无需重新编译。
|
||||
SUB2API_IMAGES_MAIN_MODEL=gpt-5.6-luna
|
||||
# OpenAI/Codex 等待上游响应头超时(秒);0 表示不使用本地响应头超时截断。
|
||||
GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=0
|
||||
# 全局强制 OpenAI Responses 上游使用 HTTP/SSE,不使用 WebSocket。
|
||||
|
||||
@@ -57,6 +57,7 @@ services:
|
||||
# Fallbacks mirror Sub2API backend defaults so omitted values preserve behavior.
|
||||
- GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false}
|
||||
- GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4}
|
||||
- SUB2API_IMAGES_MAIN_MODEL=${SUB2API_IMAGES_MAIN_MODEL:-gpt-5.6-luna}
|
||||
- GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0}
|
||||
- GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false}
|
||||
- GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true}
|
||||
|
||||
@@ -165,6 +165,7 @@ services:
|
||||
# Fallbacks mirror Sub2API backend defaults so omitted values preserve behavior.
|
||||
- GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false}
|
||||
- GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4}
|
||||
- SUB2API_IMAGES_MAIN_MODEL=${SUB2API_IMAGES_MAIN_MODEL:-gpt-5.6-luna}
|
||||
- GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0}
|
||||
- GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false}
|
||||
- GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true}
|
||||
|
||||
@@ -112,6 +112,7 @@ services:
|
||||
# Fallbacks mirror Sub2API backend defaults so omitted values preserve behavior.
|
||||
- GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false}
|
||||
- GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4}
|
||||
- SUB2API_IMAGES_MAIN_MODEL=${SUB2API_IMAGES_MAIN_MODEL:-gpt-5.6-luna}
|
||||
- GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0}
|
||||
- GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false}
|
||||
- GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true}
|
||||
|
||||
@@ -162,6 +162,7 @@ services:
|
||||
# Fallbacks mirror Sub2API backend defaults so omitted values preserve behavior.
|
||||
- GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false}
|
||||
- GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4}
|
||||
- SUB2API_IMAGES_MAIN_MODEL=${SUB2API_IMAGES_MAIN_MODEL:-gpt-5.6-luna}
|
||||
- GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0}
|
||||
- GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false}
|
||||
- GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true}
|
||||
|
||||
@@ -19,7 +19,7 @@ const openaiModels = [
|
||||
'gpt-5.3-codex-spark', 'codex-auto-review',
|
||||
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview',
|
||||
// GPT Image 系列
|
||||
'gpt-image-1', 'gpt-image-1.5', 'gpt-image-2'
|
||||
'gpt-image-1', 'gpt-image-1.5', 'gpt-image-2', 'gpt-image-2.5-flare', 'gpt-image-2.5-sunburst'
|
||||
]
|
||||
|
||||
// Anthropic Claude
|
||||
|
||||
Reference in New Issue
Block a user