diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index b57491e71b..c5f7f38fb8 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -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 { diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index 85b268fbc9..b7682cb400 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -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) } diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 6bd24cb58c..2123103595 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -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 +} diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index ccde0cbf73..0fe06a334b 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -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) diff --git a/backend/internal/service/openai_images_direct.go b/backend/internal/service/openai_images_direct.go index f10d444a98..3ed7a0f07a 100644 --- a/backend/internal/service/openai_images_direct.go +++ b/backend/internal/service/openai_images_direct.go @@ -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 } diff --git a/backend/internal/service/openai_images_direct_test.go b/backend/internal/service/openai_images_direct_test.go index 6ab08e2bad..ea723f5014 100644 --- a/backend/internal/service/openai_images_direct_test.go +++ b/backend/internal/service/openai_images_direct_test.go @@ -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 { diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index 5a0f667fb8..51bc32d6f8 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -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(