mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 00:24:38 +00:00
fix(openai): address image route review findings
This commit is contained in:
@@ -3020,7 +3020,7 @@ func (s *AccountTestService) testOpenAIImageAPIKey(c *gin.Context, ctx context.C
|
||||
return nil
|
||||
}
|
||||
|
||||
// testOpenAIImageOAuth tests OpenAI image generation using an OAuth account via Codex /responses API.
|
||||
// OAuth 图片测试与正式转发共用 Codex Images / Responses 分流规则。
|
||||
func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Context, account *Account, modelID, prompt string) error {
|
||||
credentialAccount := account
|
||||
if account.IsShadow() {
|
||||
@@ -3046,7 +3046,6 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
|
||||
c.Writer.Flush()
|
||||
|
||||
s.sendEvent(c, TestEvent{Type: "test_start", Model: modelID})
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Calling Codex /responses image tool...\n"})
|
||||
|
||||
parsed := &OpenAIImagesRequest{
|
||||
Endpoint: openAIImagesGenerationsEndpoint,
|
||||
@@ -3055,14 +3054,19 @@ 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)
|
||||
upstreamModel := account.GetMappedModel(parsed.Model)
|
||||
responsesBody, targetURL, err := buildOpenAIImagesOAuthPayload(parsed, upstreamModel)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to build image request: %s", err.Error()))
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatgptCodexAPIURL, bytes.NewReader(responsesBody))
|
||||
direct := usesCodexDirectImages(upstreamModel)
|
||||
if direct {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Calling Codex /images/generations; image model: %s\n", upstreamModel)})
|
||||
} else {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Calling Codex /responses image tool; driver: %s; image model: %s\n", openAIImagesResponsesMainModelValue(), upstreamModel)})
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(responsesBody))
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, "Failed to create request")
|
||||
}
|
||||
@@ -3084,6 +3088,10 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.Header.Set("OpenAI-Beta", "responses=experimental")
|
||||
if direct {
|
||||
req.Header.Del("OpenAI-Beta")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
canonical := resolveCodexOutboundIdentity("")
|
||||
req.Header.Set("originator", canonical.originator)
|
||||
if customUA := strings.TrimSpace(credentialAccount.GetOpenAIUserAgent()); customUA != "" {
|
||||
@@ -3102,7 +3110,7 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
|
||||
}
|
||||
resp, err := s.doOpenAIAccountTestUpstream(req, proxyURL, account, false)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Responses API request failed: %s", err.Error()))
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Image upstream request failed: %s", err.Error()))
|
||||
}
|
||||
defer func() {
|
||||
if resp != nil && resp.Body != nil {
|
||||
@@ -3114,7 +3122,7 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
|
||||
body = redactAgentIdentitySensitiveBodyForAccount(ctx, s.accountRepo, credentialAccount, body)
|
||||
message := strings.TrimSpace(extractUpstreamErrorMessage(body))
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("Responses API returned %d", resp.StatusCode)
|
||||
message = fmt.Sprintf("Image upstream returned %d", resp.StatusCode)
|
||||
}
|
||||
return s.sendErrorAndEnd(c, message)
|
||||
}
|
||||
@@ -3125,18 +3133,26 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
|
||||
}
|
||||
body = redactAgentIdentitySensitiveBodyForAccount(ctx, s.accountRepo, credentialAccount, body)
|
||||
|
||||
results, _, _, _, _, err := collectOpenAIImagesFromResponsesBody(body)
|
||||
var results []openAIResponsesImageResult
|
||||
if direct {
|
||||
results, err = parseCodexDirectImagesResponse(body)
|
||||
} else {
|
||||
if upstreamErr := extractOpenAIImagesUpstreamError(body); upstreamErr != nil {
|
||||
return s.sendErrorAndEnd(c, upstreamErr.clientMessage())
|
||||
}
|
||||
results, _, _, _, _, err = collectOpenAIImagesFromResponsesBody(body)
|
||||
}
|
||||
if err != nil {
|
||||
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")
|
||||
if direct {
|
||||
return s.sendErrorAndEnd(c, "No images returned from Codex Images API")
|
||||
}
|
||||
return s.sendErrorAndEnd(c, "No images returned from Codex Responses API")
|
||||
}
|
||||
|
||||
for _, item := range results {
|
||||
|
||||
@@ -2131,8 +2131,11 @@ func TestOpenAIGatewayServiceRecordUsage_OutputImageSizeWinsBeforeBillingAndPers
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
||||
Result: &OpenAIForwardResult{
|
||||
RequestID: "resp_image_output_size",
|
||||
Model: "gpt-image-2",
|
||||
RequestID: "resp_image_output_size",
|
||||
Model: "gpt-image-2",
|
||||
Usage: OpenAIUsage{
|
||||
ImageCacheReadTokens: 40,
|
||||
},
|
||||
ImageCount: 1,
|
||||
ImageInputSize: "1024x1024",
|
||||
ImageOutputSizes: []string{"3840x2160"},
|
||||
@@ -2162,7 +2165,7 @@ func TestOpenAIGatewayServiceRecordUsage_OutputImageSizeWinsBeforeBillingAndPers
|
||||
require.Equal(t, "3840x2160", *usageRepo.lastLog.ImageOutputSize)
|
||||
require.NotNil(t, usageRepo.lastLog.ImageSizeSource)
|
||||
require.Equal(t, ImageSizeSourceOutput, *usageRepo.lastLog.ImageSizeSource)
|
||||
require.Equal(t, map[string]int{ImageBillingSize4K: 1}, usageRepo.lastLog.ImageSizeBreakdown)
|
||||
require.Equal(t, map[string]int{ImageBillingSize4K: 1, "image_cache_read_tokens": 40}, usageRepo.lastLog.ImageSizeBreakdown)
|
||||
require.InDelta(t, 0.44, usageRepo.lastLog.TotalCost, 1e-12)
|
||||
require.InDelta(t, 0.44, usageRepo.lastLog.ActualCost, 1e-12)
|
||||
}
|
||||
|
||||
@@ -367,6 +367,14 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
)
|
||||
}
|
||||
|
||||
imageSizeBreakdown := cloneImageSizeBreakdown(result.ImageSizeBreakdown)
|
||||
if result.Usage.ImageCacheReadTokens > 0 {
|
||||
if imageSizeBreakdown == nil {
|
||||
imageSizeBreakdown = make(map[string]int)
|
||||
}
|
||||
// Keep the image cache split in the existing usage_logs JSONB payload.
|
||||
imageSizeBreakdown["image_cache_read_tokens"] = result.Usage.ImageCacheReadTokens
|
||||
}
|
||||
usageLog := &UsageLog{
|
||||
UserID: user.ID,
|
||||
APIKeyID: apiKey.ID,
|
||||
@@ -394,7 +402,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize),
|
||||
ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize),
|
||||
ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource),
|
||||
ImageSizeBreakdown: result.ImageSizeBreakdown,
|
||||
ImageSizeBreakdown: imageSizeBreakdown,
|
||||
NativeCompactionV2: input.NativeCompactionV2,
|
||||
}
|
||||
isVideoUsage := isGrokVideoUsageResult(result, billingModels)
|
||||
@@ -1145,3 +1153,14 @@ func (s *OpenAIGatewayService) UpdateCodexUsageSnapshotFromHeaders(ctx context.C
|
||||
s.updateCodexUsageSnapshot(ctx, accountID, snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func cloneImageSizeBreakdown(input map[string]int) map[string]int {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
output := make(map[string]int, len(input))
|
||||
for key, value := range input {
|
||||
output[key] = value
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
@@ -692,7 +692,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
|
||||
imageCount := parsed.N
|
||||
var firstTokenMs *int
|
||||
if parsed.Stream && isEventStreamResponse(resp.Header) {
|
||||
streamUsage, streamCount, streamSizes, ttft, err := s.handleOpenAIImagesStreamingResponse(resp, c, startTime)
|
||||
streamUsage, streamCount, streamSizes, ttft, err := s.handleOpenAIImagesStreamingResponse(resp, c, startTime, nil)
|
||||
if err != nil {
|
||||
if streamCount > 0 {
|
||||
return &OpenAIForwardResult{
|
||||
@@ -934,12 +934,8 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
|
||||
resp *http.Response,
|
||||
c *gin.Context,
|
||||
startTime time.Time,
|
||||
directRequests ...*OpenAIImagesRequest,
|
||||
direct *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 == "" {
|
||||
@@ -972,13 +968,16 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
|
||||
if streamErr != nil {
|
||||
return streamErr
|
||||
}
|
||||
if !seenSSEData || imageCounter.Count() < max(direct.N, 1) {
|
||||
if !seenSSEData || imageCounter.Count() == 0 {
|
||||
return newOpenAIUpstreamStreamReadError(ErrOpenAIUpstreamStreamTruncated)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
processSSEData := func(dataBytes []byte) {
|
||||
if streamErr != nil {
|
||||
return
|
||||
}
|
||||
seenSSEData = true
|
||||
fallbackBody.Reset()
|
||||
fallbackBytes = 0
|
||||
@@ -1000,12 +999,18 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
|
||||
}
|
||||
if upstreamErr := openAIImagesUpstreamErrorFromSSEPayload(dataBytes); upstreamErr != nil {
|
||||
streamErr = upstreamErr
|
||||
if IsOpenAIImagesRetryableUpstreamError(upstreamErr) && imageCounter.Count() == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
if !gjson.ValidBytes(dataBytes) {
|
||||
streamErr = newOpenAIUpstreamStreamReadError(fmt.Errorf("invalid image stream JSON"))
|
||||
return
|
||||
}
|
||||
eventType := gjson.GetBytes(dataBytes, "type").String()
|
||||
if direct != nil && strings.TrimSpace(direct.Model) != "" {
|
||||
dataBytes, _ = sjson.SetBytes(dataBytes, "model", strings.TrimSpace(direct.Model))
|
||||
}
|
||||
// 原生编辑事件可能仍使用 image_generation 前缀;对外维持既有编辑事件名。
|
||||
if strings.HasPrefix(eventType, "image_generation.") && direct.IsEdits() {
|
||||
eventType = strings.Replace(eventType, "image_generation.", "image_edit.", 1)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -11,6 +13,20 @@ import (
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
type openAIImagesForceResponsesContextKey struct{}
|
||||
|
||||
func withOpenAIImagesForceResponses(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, openAIImagesForceResponsesContextKey{}, true)
|
||||
}
|
||||
|
||||
func isOpenAIImagesForceResponses(ctx context.Context) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
forced, _ := ctx.Value(openAIImagesForceResponsesContextKey{}).(bool)
|
||||
return forced
|
||||
}
|
||||
|
||||
// 显式列出已接入的模型,不把未来模型或未知快照自动送到直调端点。
|
||||
func usesCodexDirectImages(model string) bool {
|
||||
switch strings.TrimSpace(model) {
|
||||
@@ -35,47 +51,50 @@ func buildOpenAIImagesOAuthPayload(parsed *OpenAIImagesRequest, model string) ([
|
||||
if strings.TrimSpace(parsed.Prompt) == "" {
|
||||
return nil, "", fmt.Errorf("prompt is required")
|
||||
}
|
||||
// JSON 参数保持透传;multipart 则将已校验的字段与上传图片转换为 JSON。
|
||||
body := []byte(`{}`)
|
||||
// 只把已解析并校验的图片字段发送给上游,避免客户端注入任意 JSON 字段。
|
||||
payload := make(map[string]any, 16)
|
||||
payload["model"] = model
|
||||
prompt := parsed.Prompt
|
||||
if !parsed.Multipart && gjson.ValidBytes(parsed.Body) {
|
||||
body = append([]byte(nil), parsed.Body...)
|
||||
if rawPrompt := gjson.GetBytes(parsed.Body, "prompt").String(); rawPrompt != "" {
|
||||
prompt = rawPrompt
|
||||
}
|
||||
}
|
||||
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 }{
|
||||
payload["prompt"] = prompt
|
||||
for _, field := range []struct {
|
||||
key string
|
||||
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 value := strings.TrimSpace(field.value); value != "" {
|
||||
payload[field.key] = value
|
||||
}
|
||||
}
|
||||
if parsed.N > 0 {
|
||||
body, _ = sjson.SetBytes(body, "n", parsed.N)
|
||||
if parsed.N > 1 {
|
||||
payload["n"] = parsed.N
|
||||
}
|
||||
if parsed.OutputCompression != nil {
|
||||
body, _ = sjson.SetBytes(body, "output_compression", *parsed.OutputCompression)
|
||||
payload["output_compression"] = *parsed.OutputCompression
|
||||
}
|
||||
if parsed.PartialImages != nil {
|
||||
body, _ = sjson.SetBytes(body, "partial_images", *parsed.PartialImages)
|
||||
payload["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")
|
||||
payload["stream"] = true
|
||||
}
|
||||
|
||||
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 _, imageURL := range parsed.InputImageURLs {
|
||||
if imageURL = strings.TrimSpace(imageURL); imageURL != "" {
|
||||
images = append(images, map[string]string{"image_url": imageURL})
|
||||
}
|
||||
}
|
||||
for _, upload := range parsed.Uploads {
|
||||
url, err := openAIImageUploadToDataURL(upload)
|
||||
@@ -87,8 +106,8 @@ func buildOpenAIImagesOAuthPayload(parsed *OpenAIImagesRequest, model string) ([
|
||||
if len(images) == 0 {
|
||||
return nil, "", fmt.Errorf("image input is required")
|
||||
}
|
||||
body, _ = sjson.SetBytes(body, "images", images)
|
||||
mask := parsed.MaskImageURL
|
||||
payload["images"] = images
|
||||
mask := strings.TrimSpace(parsed.MaskImageURL)
|
||||
if parsed.MaskUpload != nil {
|
||||
var err error
|
||||
mask, err = openAIImageUploadToDataURL(*parsed.MaskUpload)
|
||||
@@ -97,9 +116,13 @@ func buildOpenAIImagesOAuthPayload(parsed *OpenAIImagesRequest, model string) ([
|
||||
}
|
||||
}
|
||||
if mask != "" {
|
||||
body, _ = sjson.SetBytes(body, "mask.image_url", mask)
|
||||
payload["mask"] = map[string]string{"image_url": mask}
|
||||
}
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("marshal Codex Images request: %w", err)
|
||||
}
|
||||
return body, strings.TrimSuffix(chatgptCodexURL, "/responses") + endpoint, nil
|
||||
}
|
||||
|
||||
@@ -149,8 +172,13 @@ func codexDirectImageURL(body []byte, path, outputFormat string) []byte {
|
||||
}
|
||||
|
||||
func isOpenAIImagesMainModelError(status int, body []byte) bool {
|
||||
return isOpenAICodexPlanGatedModelError(status, body) &&
|
||||
strings.Contains(extractUpstreamErrorMessage(body), openAIImagesResponsesMainModel)
|
||||
if !isOpenAICodexPlanGatedModelError(status, body) {
|
||||
return false
|
||||
}
|
||||
message := extractUpstreamErrorMessage(body)
|
||||
model := openAIImagesResponsesMainModelValue()
|
||||
return strings.Contains(message, "'"+model+"'") ||
|
||||
strings.Contains(message, `"`+model+`"`)
|
||||
}
|
||||
|
||||
// Images 端点只输出图片;未提供输出分类时,output_tokens 全部是图片 token。
|
||||
@@ -195,6 +223,12 @@ func (s *OpenAIGatewayService) handleCodexDirectImagesNonStreamingResponse(resp
|
||||
observer.Observe(result.Model, true)
|
||||
}
|
||||
}
|
||||
clientModel := strings.TrimSpace(parsed.Model)
|
||||
if clientModel != "" {
|
||||
for i := range gjson.GetBytes(body, "data").Array() {
|
||||
body, _ = sjson.SetBytes(body, fmt.Sprintf("data.%d.model", i), clientModel)
|
||||
}
|
||||
}
|
||||
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)
|
||||
@@ -216,6 +250,12 @@ func (s *OpenAIGatewayService) handleCodexDirectImagesNonStreamingResponse(resp
|
||||
}
|
||||
}
|
||||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||||
c.Data(resp.StatusCode, "application/json", body)
|
||||
contentType := "application/json"
|
||||
if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled {
|
||||
if upstreamType := strings.TrimSpace(resp.Header.Get("Content-Type")); upstreamType != "" {
|
||||
contentType = upstreamType
|
||||
}
|
||||
}
|
||||
c.Data(resp.StatusCode, contentType, body)
|
||||
return usage, len(results), openAIResponsesImageResultSizes(results), nil
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestCodexDirectImagesRouting(t *testing.T) {
|
||||
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())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "extra").Exists())
|
||||
for _, key := range []string{"tools", "reasoning", "instructions", "response_format", "stream"} {
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, key).Exists(), key)
|
||||
}
|
||||
@@ -80,23 +80,33 @@ func TestCodexDirectImagesMappingBeforeRouting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexDirectImagesHTTPErrorDoesNotFallback(t *testing.T) {
|
||||
for _, status := range []int{400, 401, 403, 429, 500, 502, 503} {
|
||||
func TestCodexDirectImagesHTTPErrorFallbacksOnlyWhenEndpointUnavailable(t *testing.T) {
|
||||
for _, status := range []int{400, 401, 403, 429, 500, 502, 503, 404, 405} {
|
||||
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
|
||||
if calls == 1 {
|
||||
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
|
||||
}
|
||||
require.Contains(t, req.URL.Path, "/backend-api/codex/responses")
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"text/event-stream"}}, Body: io.NopCloser(strings.NewReader("data: {\"type\":\"response.completed\",\"response\":{\"output\":[{\"type\":\"image_generation_call\",\"result\":\"aGVsbG8=\"}]}}\n\n"))}, 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)
|
||||
result, err := svc.ForwardImages(context.Background(), c, directImagesTestAccount(), body, parsed, "")
|
||||
if status == http.StatusNotFound || status == http.StatusMethodNotAllowed {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, 2, calls)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
require.Equal(t, 1, calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -143,11 +153,15 @@ func TestCodexDirectImagesStreaming(t *testing.T) {
|
||||
{"duplicate", "complete,complete", 1, false, false},
|
||||
{"disconnect", "partial,complete", 1, false, true},
|
||||
{"truncated", "partial", 0, true, false},
|
||||
{"partial_requested_multiple", "complete", 1, false, 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"}`)
|
||||
if test.name == "partial_requested_multiple" {
|
||||
body = []byte(`{"model":"gpt-image-2","prompt":"edit","n":2,"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 {
|
||||
|
||||
@@ -383,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", openAIImagesResponsesMainModel)
|
||||
req, _ = sjson.SetBytes(req, "model", openAIImagesResponsesMainModelValue())
|
||||
req, _ = sjson.SetBytes(req, "instructions", openAIImagesVerbatimPromptInstructions)
|
||||
|
||||
input := []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`)
|
||||
@@ -1803,7 +1803,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
|
||||
if err := validateOpenAIImagesModel(upstreamModel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
direct := usesCodexDirectImages(upstreamModel)
|
||||
direct := usesCodexDirectImages(upstreamModel) && !isOpenAIImagesForceResponses(ctx)
|
||||
beginUpstreamResponseModelObservation(c)
|
||||
SetOpsUpstreamModel(c, upstreamModel)
|
||||
logger.LegacyPrintf(
|
||||
@@ -1822,7 +1822,14 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
responsesBody, targetURL, err := buildOpenAIImagesOAuthPayload(parsed, upstreamModel)
|
||||
var responsesBody []byte
|
||||
var targetURL string
|
||||
if direct {
|
||||
responsesBody, targetURL, err = buildOpenAIImagesOAuthPayload(parsed, upstreamModel)
|
||||
} else {
|
||||
responsesBody, err = buildOpenAIImagesResponsesRequest(parsed, upstreamModel)
|
||||
targetURL = chatgptCodexURL
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1874,6 +1881,9 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
|
||||
respBody := s.readUpstreamErrorBody(resp)
|
||||
_ = resp.Body.Close()
|
||||
respBody = s.redactAgentIdentitySensitiveBody(upstreamCtx, account, respBody)
|
||||
if direct && (resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed) {
|
||||
return s.forwardOpenAIImagesOAuth(withOpenAIImagesForceResponses(ctx), c, account, parsed, channelMappedModel)
|
||||
}
|
||||
if !agentIdentityTaskRecoveryWasTried(ctx) && s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, respBody) {
|
||||
expectedTaskID := account.GetCredential("task_id")
|
||||
if err := s.recoverAgentIdentityTask(ctx, account, expectedTaskID); err != nil {
|
||||
@@ -1966,7 +1976,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
|
||||
if direct {
|
||||
usage, imageCount, imageOutputSizes, err = s.handleCodexDirectImagesNonStreamingResponse(resp, c, parsed)
|
||||
} else {
|
||||
usage, imageCount, imageOutputSizes, err = s.handleOpenAIImagesOAuthNonStreamingResponse(resp, c, parsed.ResponseFormat, upstreamModel)
|
||||
usage, imageCount, imageOutputSizes, err = s.handleOpenAIImagesOAuthNonStreamingResponse(resp, c, parsed.ResponseFormat, requestModel)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIImagesOAuthResponseError(
|
||||
|
||||
Reference in New Issue
Block a user