Skip to content

fix(openai): support streaming image relay and image edit for images API #4608

Open
gaoren002 wants to merge 2 commits intoQuantumNous:mainfrom
gaoren002:pr/openai-image-stream-keepalive-20260504130050
Open

fix(openai): support streaming image relay and image edit for images API #4608
gaoren002 wants to merge 2 commits intoQuantumNous:mainfrom
gaoren002:pr/openai-image-stream-keepalive-20260504130050

Conversation

@gaoren002
Copy link
Copy Markdown
Contributor

@gaoren002 gaoren002 commented May 4, 2026

⚠️ 提交说明 / PR Notice

Important

  • 本描述已按代码逻辑人工整理,未直接粘贴原始排查记录。

📝 变更描述 / Description

本 PR 修复 OpenAI 图片生成/编辑在 stream=true 场景下没有按流式响应处理的问题。

此前 ImageRequest.IsStream() 固定返回 false,即使客户端传入 stream=true,图片接口仍会走非流式响应路径。对于耗时较长的图片生成/编辑请求,下游在上游完成前收不到任何响应数据,容易被 CDN / Cloudflare 等入口超时断开。

本次改动让图片请求正确识别 stream 参数,并在 OpenAI 图片生成与图片编辑接口中增加 SSE 转发处理:上游返回的 event: / data: 行会原样写回客户端,兼容 image_generation.partial_image、完成事件和 [DONE],同时从流式数据中提取 usage,保持计费链路可用。

另外,图片编辑接口使用 multipart/form-data。校验阶段如果直接调用 c.MultipartForm(),会消费原始请求体,后续转发到 OpenAI 时可能导致 multipart 内容不可复用。本 PR 改为使用可复用的 multipart 解析方式,并回填 c.Request.MultipartForm / c.Request.PostForm,确保校验后仍能完整重建并转发图片、mask 和普通表单字段。

🚀 变更类型 / Type of change

  • 🐛 Bug 修复 (Bug fix) - 修复图片流式请求未按流处理,以及图片编辑 multipart body 被提前消费的问题
  • ✨ 新功能 (New feature)
  • ⚡ 性能优化 / 重构 (Refactor)
  • 📝 文档更新 (Documentation)

🔗 关联任务 / Related Issue

✅ 提交前检查项 / Checklist

  • 人工确认: 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
  • 非重复提交: 已搜索现有 Issues 与 PRs;OpenAI Images API 长耗时请求被 Cloudflare 524 切断 stream=true 未按 Images SSE 处理且缺少结果取回机制 #4478 是精确相关 issue,未发现同等修复已合并到当前 main
  • Bug fix 说明: 此 PR 修复的是请求参数已存在但未被流式链路识别、以及 multipart 校验后无法可靠转发的问题。
  • 变更理解: 我已理解这些更改的工作原理及可能影响。
  • 范围聚焦: 本 PR 仅包含 OpenAI 图片流式转发和图片编辑 multipart 复用相关改动。
  • 本地验证: 已在本地运行相关 Go 测试并通过。
  • 安全合规: 代码中无敏感凭据,且符合项目代码规范。

📸 运行证明 / Proof of Work

本地测试命令:

go test ./model ./controller ./relay/helper ./relay/channel/openai ./dto

测试结果:

ok  github.com/QuantumNous/new-api/model
ok  github.com/QuantumNous/new-api/controller
ok  github.com/QuantumNous/new-api/relay/helper
ok  github.com/QuantumNous/new-api/relay/channel/openai
ok  github.com/QuantumNous/new-api/dto

PR 分支:

pr/openai-image-stream-keepalive-20260504130050

提交:

84051a50a fix(openai): support streaming image relay
728a45c20 fix(openai): keep image edit multipart body reusable

Summary by CodeRabbit

  • New Features

    • Image generation and edits now support SSE-style streaming with progressive delivery and usage forwarding.
  • Bug Fixes

    • Incoming streaming flag for image requests is honored.
    • Usage metrics normalized to avoid token double-counting.
    • Multipart image edits preserve form fields and file parts even when the original form is missing.
    • Stream status is no longer overwritten and scanner buffer sizing is improved.
  • Tests

    • Added tests for streaming, multipart edit handling, SSE forwarding, usage normalization, and invalid stream values.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 4, 2026

Walkthrough

Image streaming for OpenAI Images endpoints is enabled: ImageRequest.IsStream now reflects the incoming stream field; multipart image requests parse and preserve stream; routing chooses an SSE image stream handler that forwards upstream SSE lines, normalizes usage, and applies post-processing.

Changes

OpenAI Images Streaming Support

Layer / File(s) Summary
DTO Behavior
dto/openai_image.go, dto/openai_image_test.go
ImageRequest.IsStream() now returns i.Stream. Added test verifying JSON stream: true sets Stream and IsStream(nil) returns true.
Request Validation
relay/helper/valid_request.go, relay/helper/openai_image_request_test.go
Multipart image edits path uses common.ParseMultipartFormReusable, assigns parsed MultipartForm/PostForm, parses stream via strconv.ParseBool, and errors on invalid values; tests cover valid/invalid stream parsing and body preservation.
Multipart Conversion
relay/channel/openai/adaptor.go, relay/channel/openai/image_edit_test.go
ConvertImageRequest uses reusable multipart parsing when c.Request.MultipartForm is nil and preserves PostForm values; tests ensure converted multipart keeps stream, other fields, and file parts.
Relay Routing
relay/channel/openai/adaptor.go
For RelayModeImagesGenerations and RelayModeImagesEdits, routing now chooses OpenaiImageStreamHandler when info.IsStream is true, otherwise OpenaiHandlerWithUsage.
Streaming Handler Core
relay/channel/openai/relay-openai.go, relay/channel/openai/image_stream_test.go
Added OpenaiImageStreamHandler to validate SSE responses, scan and forward SSE lines to clients, parse data: events into dto.SimpleResponse, normalize usage via normalizeOpenAIUsage, update info.StreamStatus end reasons, and call applyUsagePostProcessing at completion; tests validate SSE forwarding and usage normalization.
Stream Infrastructure
relay/helper/stream_scanner.go
Exported GetScannerBufferSize() and made StreamScannerHandler initialize info.StreamStatus only if nil; scanner uses GetScannerBufferSize().

Sequence Diagram

sequenceDiagram
    participant Client as Client
    participant NewAPI as NewAPI
    participant Upstream as Upstream
    participant SSEHandler as OpenaiImageStreamHandler

    Client->>NewAPI: POST /v1/images/edits (stream=true, multipart)
    NewAPI->>NewAPI: Parse request (GetAndValidOpenAIImageRequest -> stream=true)
    NewAPI->>NewAPI: info.IsStream() == true
    NewAPI->>Upstream: Forward request to provider
    Upstream-->>NewAPI: SSE response (text/event-stream)
    NewAPI->>SSEHandler: OpenaiImageStreamHandler(resp)
    SSEHandler->>Client: Set Content-Type: text/event-stream
    loop For each SSE line
        Upstream-->>SSEHandler: data: {...partial_image...} / usage
        SSEHandler->>Client: Forward line (write + flush)
        SSEHandler->>SSEHandler: Parse/normalize usage from data (store last valid)
    end
    Upstream-->>SSEHandler: [DONE] or EOF
    SSEHandler->>SSEHandler: Apply usage post-processing using last data
    SSEHandler-->>NewAPI: Return usage
    NewAPI-->>Client: Complete response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • creamlike1024

"I'm a rabbit with bytes in my paws,
I hop through SSE and mend the flaws,
Forwarding images line by line,
Counting tokens, keeping usage fine,
Now long jobs stream and finish on time."

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title directly addresses the main change: adding support for streaming image relay and image editing for the OpenAI Images API, which aligns with the core objective of implementing SSE semantics for image endpoints.
Linked Issues check ✅ Passed The code changes comprehensively implement all coding requirements from issue #4478: ImageRequest.IsStream() now returns the Stream field [dto/openai_image.go], SSE forwarding is added for image generation/edits [relay/channel/openai/relay-openai.go], multipart parsing is made reusable [relay/helper/valid_request.go, relay/channel/openai/adaptor.go], and usage extraction from streaming data is preserved.
Out of Scope Changes check ✅ Passed All changes are scoped to implementing streaming support for image APIs and fixing multipart handling. The modifications to StreamScannerHandler (checking if StreamStatus is nil before initializing) and exporting GetScannerBufferSize are supporting changes necessary for the new OpenaiImageStreamHandler, with no unrelated additions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@relay/channel/openai/image_stream_test.go`:
- Around line 16-21: The test TestOpenaiImageStreamHandlerForwardsSSEAndUsage
sets global Gin mode via gin.SetMode(gin.TestMode) but doesn't restore the
previous mode; update the test to capture the current mode (e.g., prevMode :=
gin.Mode()), call gin.SetMode(gin.TestMode) as done now, and add a t.Cleanup
closure that restores gin.SetMode(prevMode) alongside the existing
constant.StreamingTimeout restore so the global Gin mode is reverted after the
test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3b13492d-e5c4-4394-8191-5886936a74d5

📥 Commits

Reviewing files that changed from the base of the PR and between dac55f0 and 728a45c.

📒 Files selected for processing (9)
  • dto/openai_image.go
  • dto/openai_image_test.go
  • relay/channel/openai/adaptor.go
  • relay/channel/openai/image_edit_test.go
  • relay/channel/openai/image_stream_test.go
  • relay/channel/openai/relay-openai.go
  • relay/helper/openai_image_request_test.go
  • relay/helper/stream_scanner.go
  • relay/helper/valid_request.go

Comment thread relay/channel/openai/image_stream_test.go
@gaoren002 gaoren002 force-pushed the pr/openai-image-stream-keepalive-20260504130050 branch 2 times, most recently from 2d27fba to c98a5b4 Compare May 4, 2026 13:29
@gaoren002
Copy link
Copy Markdown
Contributor Author

本次修复重点考虑了计费逻辑,应该能较完满解决问题。
但是只能在上游返回 usage 的情况下保持现有图片 token 计费语义
无 usage 的异常/中断场景仍沿用现有兜底行为

@gaoren002 gaoren002 force-pushed the pr/openai-image-stream-keepalive-20260504130050 branch from c98a5b4 to 55f3767 Compare May 4, 2026 13:40
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
relay/helper/openai_image_request_test.go (1)

18-52: ⚡ Quick win

LGTM — the happy-path multipart stream test is well-structured

The body-reusability assertion at lines 44–46 correctly verifies that ParseMultipartFormReusable restores c.Request.Body after first read. The subsequent ParseMultipartFormReusable(c) call at line 48 correctly relies on c.Request.MultipartForm having been pre-populated by valid_request.go (line 155), which prevents re-reading the already-drained body.

Optional: The error branch at valid_request.go lines 164–166 (malformed stream value → return error) has no test coverage. Consider adding:

func TestGetAndValidOpenAIImageRequestMultipartStreamInvalidValue(t *testing.T) {
    gin.SetMode(gin.TestMode)
    var body bytes.Buffer
    writer := multipart.NewWriter(&body)
    require.NoError(t, writer.WriteField("model", "gpt-image-1"))
    require.NoError(t, writer.WriteField("stream", "notabool"))
    require.NoError(t, writer.Close())

    recorder := httptest.NewRecorder()
    c, _ := gin.CreateTestContext(recorder)
    c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
    c.Request.Header.Set("Content-Type", writer.FormDataContentType())

    _, err := GetAndValidOpenAIImageRequest(c, relayconstant.RelayModeImagesEdits)
    require.Error(t, err)
    require.Contains(t, err.Error(), "invalid stream value")
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/helper/openai_image_request_test.go` around lines 18 - 52, Add a
negative test that covers the malformed "stream" value path in
GetAndValidOpenAIImageRequest: create a multipart request with model
"gpt-image-1" and stream set to "notabool", set Content-Type to the writer's
FormDataContentType, call GetAndValidOpenAIImageRequest(c,
relayconstant.RelayModeImagesEdits) and assert it returns an error containing
"invalid stream value" (e.g., name the test
TestGetAndValidOpenAIImageRequestMultipartStreamInvalidValue and mirror the
existing multipart setup/recorder/gin context used in
TestGetAndValidOpenAIImageRequestMultipartStream).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@relay/channel/openai/relay-openai.go`:
- Around line 604-620: OpenaiImageStreamHandler currently forces SSE headers for
every response; instead guard by checking resp.StatusCode and
resp.Header["Content-Type"] (or resp.Header.Get("Content-Type")) for an SSE
content type and a successful status before calling
helper.SetEventStreamHeaders; if the response is non-200 or not SSE (e.g.,
OpenAI JSON error), do NOT set SSE headers—read the body, return the original
status and a types.NewOpenAIError (including the response body/error text) so
the client gets the real HTTP error, and keep defer
service.CloseResponseBodyGracefully(resp) and existing handling of
info.StreamStatus unchanged.

---

Nitpick comments:
In `@relay/helper/openai_image_request_test.go`:
- Around line 18-52: Add a negative test that covers the malformed "stream"
value path in GetAndValidOpenAIImageRequest: create a multipart request with
model "gpt-image-1" and stream set to "notabool", set Content-Type to the
writer's FormDataContentType, call GetAndValidOpenAIImageRequest(c,
relayconstant.RelayModeImagesEdits) and assert it returns an error containing
"invalid stream value" (e.g., name the test
TestGetAndValidOpenAIImageRequestMultipartStreamInvalidValue and mirror the
existing multipart setup/recorder/gin context used in
TestGetAndValidOpenAIImageRequestMultipartStream).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8859bf41-d1cd-493f-921d-35f7672b79b3

📥 Commits

Reviewing files that changed from the base of the PR and between 728a45c and c98a5b4.

📒 Files selected for processing (6)
  • relay/channel/openai/image_edit_test.go
  • relay/channel/openai/image_stream_test.go
  • relay/channel/openai/relay-openai.go
  • relay/helper/openai_image_request_test.go
  • relay/helper/stream_scanner.go
  • relay/helper/valid_request.go
✅ Files skipped from review due to trivial changes (1)
  • relay/channel/openai/image_edit_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • relay/helper/stream_scanner.go

Comment thread relay/channel/openai/relay-openai.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
relay/channel/openai/image_stream_test.go (1)

19-23: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Restore Gin mode in test cleanup to avoid global-state leakage.

At Line 19, the test mutates global Gin mode but only restores constant.StreamingTimeout. Please also restore the previous Gin mode in t.Cleanup.

Suggested fix
 func TestOpenaiImageStreamHandlerForwardsSSEAndUsage(t *testing.T) {
-	gin.SetMode(gin.TestMode)
+	oldMode := gin.Mode()
+	gin.SetMode(gin.TestMode)
+	t.Cleanup(func() { gin.SetMode(oldMode) })
 
 	oldTimeout := constant.StreamingTimeout
 	constant.StreamingTimeout = 30
 	t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
#!/bin/bash
# Verify gin mode mutation is paired with cleanup restore in this test file.
rg -n -C2 'gin\.SetMode\(gin\.TestMode\)|gin\.Mode\(\)|t\.Cleanup\(func\(\)\s*\{\s*gin\.SetMode' relay/channel/openai/image_stream_test.go
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/openai/image_stream_test.go` around lines 19 - 23, The test
sets global Gin mode via gin.SetMode(gin.TestMode) but only restores
constant.StreamingTimeout in t.Cleanup, causing global-state leakage; update the
cleanup to capture the original Gin mode with gin.Mode() before calling
gin.SetMode and restore it inside the existing t.Cleanup alongside restoring
constant.StreamingTimeout so both constant.StreamingTimeout and Gin's mode
(captured via gin.Mode()) are reset after the test.
relay/channel/openai/relay-openai.go (1)

616-617: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve upstream non-SSE/error responses before forcing SSE output.

At Line 616, helper.SetEventStreamHeaders(c) is unconditional. If upstream returns non-2xx or JSON error, this path can rewrite the response contract and mask real error semantics.

Suggested fix
 func OpenaiImageStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
 	if resp == nil || resp.Body == nil {
 		logger.LogError(c, "invalid image stream response")
 		return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
 	}
-	defer service.CloseResponseBodyGracefully(resp)
-
-	usage := &dto.Usage{}
-	var lastStreamData []byte
-
-	helper.SetEventStreamHeaders(c)
+	contentType := strings.ToLower(resp.Header.Get("Content-Type"))
+	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices ||
+		!strings.Contains(contentType, "text/event-stream") {
+		// keep non-stream/error behavior intact
+		return OpenaiHandlerWithUsage(c, info, resp)
+	}
+	defer service.CloseResponseBodyGracefully(resp)
+
+	usage := &dto.Usage{}
+	var lastStreamData []byte
+
+	helper.SetEventStreamHeaders(c)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/openai/relay-openai.go` around lines 616 - 617,
helper.SetEventStreamHeaders(c) is being called unconditionally which forces SSE
headers even when the upstream response is non-2xx or contains a JSON error;
change the flow so that before calling helper.SetEventStreamHeaders(c) you check
the upstream status/response (use info.StreamStatus and any upstream response
body/status) and only set SSE headers when the upstream returns a successful
stream (e.g., 2xx and not an error JSON). In practice, move or gate the
helper.SetEventStreamHeaders(c) call behind a conditional that verifies
info.StreamStatus indicates a streaming success and avoid mutating headers when
upstream returned an error status or non-SSE payload.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@relay/channel/openai/image_stream_test.go`:
- Around line 19-23: The test sets global Gin mode via gin.SetMode(gin.TestMode)
but only restores constant.StreamingTimeout in t.Cleanup, causing global-state
leakage; update the cleanup to capture the original Gin mode with gin.Mode()
before calling gin.SetMode and restore it inside the existing t.Cleanup
alongside restoring constant.StreamingTimeout so both constant.StreamingTimeout
and Gin's mode (captured via gin.Mode()) are reset after the test.

In `@relay/channel/openai/relay-openai.go`:
- Around line 616-617: helper.SetEventStreamHeaders(c) is being called
unconditionally which forces SSE headers even when the upstream response is
non-2xx or contains a JSON error; change the flow so that before calling
helper.SetEventStreamHeaders(c) you check the upstream status/response (use
info.StreamStatus and any upstream response body/status) and only set SSE
headers when the upstream returns a successful stream (e.g., 2xx and not an
error JSON). In practice, move or gate the helper.SetEventStreamHeaders(c) call
behind a conditional that verifies info.StreamStatus indicates a streaming
success and avoid mutating headers when upstream returned an error status or
non-SSE payload.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fcf2b9f7-9e88-48f3-9a4d-0169ef31bc39

📥 Commits

Reviewing files that changed from the base of the PR and between c98a5b4 and 55f3767.

📒 Files selected for processing (7)
  • dto/openai_image_test.go
  • relay/channel/openai/image_edit_test.go
  • relay/channel/openai/image_stream_test.go
  • relay/channel/openai/relay-openai.go
  • relay/helper/openai_image_request_test.go
  • relay/helper/stream_scanner.go
  • relay/helper/valid_request.go
✅ Files skipped from review due to trivial changes (3)
  • dto/openai_image_test.go
  • relay/channel/openai/image_edit_test.go
  • relay/helper/valid_request.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • relay/helper/openai_image_request_test.go
  • relay/helper/stream_scanner.go

@gaoren002 gaoren002 force-pushed the pr/openai-image-stream-keepalive-20260504130050 branch from 55f3767 to cfc9a34 Compare May 4, 2026 13:49
@gaoren002 gaoren002 force-pushed the pr/openai-image-stream-keepalive-20260504130050 branch from cfc9a34 to f706730 Compare May 4, 2026 13:55
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
relay/channel/openai/image_stream_test.go (1)

51-61: ⚡ Quick win

data: [DONE] forwarding is not asserted.

The test verifies that SSE event lines and the usage payload are forwarded, but it never checks that the stream-termination sentinel (data: [DONE]) is present in the recorded body. The handler forwards all lines unconditionally, so this is a coverage gap rather than a production defect — but a targeted regression could silently remove [DONE] forwarding without tripping any existing assertion.

✅ Proposed addition
 require.Contains(t, recorder.Body.String(), `data: {"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"input_tokens_details":{"image_tokens":2,"text_tokens":1}}}`)
+require.Contains(t, recorder.Body.String(), `data: [DONE]`)
 require.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type"))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/openai/image_stream_test.go` around lines 51 - 61, The test for
OpenaiImageStreamHandler misses asserting that the SSE stream terminator "data:
[DONE]" is forwarded; update the test in
relay/channel/openai/image_stream_test.go to also assert that
recorder.Body.String() (or equivalent recorded output) contains the literal
`data: [DONE]`, ensuring the handler's forwarding of the stream-termination
sentinel is covered along with the existing checks for partial_image events and
usage payloads.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@relay/channel/openai/image_stream_test.go`:
- Around line 51-61: The test for OpenaiImageStreamHandler misses asserting that
the SSE stream terminator "data: [DONE]" is forwarded; update the test in
relay/channel/openai/image_stream_test.go to also assert that
recorder.Body.String() (or equivalent recorded output) contains the literal
`data: [DONE]`, ensuring the handler's forwarding of the stream-termination
sentinel is covered along with the existing checks for partial_image events and
usage payloads.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d47a1817-cb81-45ea-86f1-80384ff32ed3

📥 Commits

Reviewing files that changed from the base of the PR and between 55f3767 and cfc9a34.

📒 Files selected for processing (7)
  • dto/openai_image_test.go
  • relay/channel/openai/image_edit_test.go
  • relay/channel/openai/image_stream_test.go
  • relay/channel/openai/relay-openai.go
  • relay/helper/openai_image_request_test.go
  • relay/helper/stream_scanner.go
  • relay/helper/valid_request.go
✅ Files skipped from review due to trivial changes (2)
  • relay/channel/openai/image_edit_test.go
  • relay/helper/stream_scanner.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • relay/helper/openai_image_request_test.go
  • dto/openai_image_test.go
  • relay/helper/valid_request.go
  • relay/channel/openai/relay-openai.go

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@relay/channel/openai/relay-openai.go`:
- Around line 594-599: The normalization block copies several token subfields
from usage.InputTokensDetails to usage.PromptTokensDetails but omits
CachedCreationTokens, so add copying of that field as well: when
usage.InputTokensDetails != nil assign
usage.PromptTokensDetails.CachedCreationTokens =
usage.InputTokensDetails.CachedCreationTokens alongside the existing assignments
(i.e., update the block that references usage.InputTokensDetails and
usage.PromptTokensDetails to include CachedCreationTokens).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dc2ddfb7-4a53-4df2-b3fc-fde827b24322

📥 Commits

Reviewing files that changed from the base of the PR and between cfc9a34 and f706730.

📒 Files selected for processing (8)
  • dto/openai_image_test.go
  • relay/channel/openai/adaptor.go
  • relay/channel/openai/image_edit_test.go
  • relay/channel/openai/image_stream_test.go
  • relay/channel/openai/relay-openai.go
  • relay/helper/openai_image_request_test.go
  • relay/helper/stream_scanner.go
  • relay/helper/valid_request.go
✅ Files skipped from review due to trivial changes (3)
  • relay/channel/openai/image_stream_test.go
  • relay/helper/valid_request.go
  • dto/openai_image_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • relay/channel/openai/image_edit_test.go
  • relay/helper/stream_scanner.go
  • relay/helper/openai_image_request_test.go

Comment on lines +594 to +599
if usage.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
usage.PromptTokensDetails.ImageTokens = usage.InputTokensDetails.ImageTokens
usage.PromptTokensDetails.TextTokens = usage.InputTokensDetails.TextTokens
usage.PromptTokensDetails.AudioTokens = usage.InputTokensDetails.AudioTokens
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Copy cached_creation_tokens during usage normalization.

dto.InputTokenDetails includes CachedCreationTokens, but this mapper drops it while copying the sibling fields. If an upstream image event only reports input_tokens_details.cached_creation_tokens, downstream billing/cache accounting will see 0 after normalization.

Suggested fix
 	if usage.InputTokensDetails != nil {
 		usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
+		usage.PromptTokensDetails.CachedCreationTokens = usage.InputTokensDetails.CachedCreationTokens
 		usage.PromptTokensDetails.ImageTokens = usage.InputTokensDetails.ImageTokens
 		usage.PromptTokensDetails.TextTokens = usage.InputTokensDetails.TextTokens
 		usage.PromptTokensDetails.AudioTokens = usage.InputTokensDetails.AudioTokens
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/openai/relay-openai.go` around lines 594 - 599, The
normalization block copies several token subfields from usage.InputTokensDetails
to usage.PromptTokensDetails but omits CachedCreationTokens, so add copying of
that field as well: when usage.InputTokensDetails != nil assign
usage.PromptTokensDetails.CachedCreationTokens =
usage.InputTokensDetails.CachedCreationTokens alongside the existing assignments
(i.e., update the block that references usage.InputTokensDetails and
usage.PromptTokensDetails to include CachedCreationTokens).

@gaoren002
Copy link
Copy Markdown
Contributor Author

@coderabbitai resume

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 4, 2026

✅ Actions performed

Reviews resumed.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@relay/channel/openai/image_edit_test.go`:
- Around line 75-118: The test
TestConvertImageEditRequestParsesReusableMultipartWhenFormIsMissing needs to
exercise the "stream" path as well: include the "stream" field in the multipart
body (e.g., add writer.WriteField("stream", "true")) and set the
dto.ImageRequest.Stream flag (e.g., request.Stream = true) before calling
(&Adaptor{}).ConvertImageRequest so the fallback reusable-multipart parsing
covers the stream branch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 41d4fe8d-d518-4a7c-99e8-6a600afa91c6

📥 Commits

Reviewing files that changed from the base of the PR and between cfc9a34 and f706730.

📒 Files selected for processing (8)
  • dto/openai_image_test.go
  • relay/channel/openai/adaptor.go
  • relay/channel/openai/image_edit_test.go
  • relay/channel/openai/image_stream_test.go
  • relay/channel/openai/relay-openai.go
  • relay/helper/openai_image_request_test.go
  • relay/helper/stream_scanner.go
  • relay/helper/valid_request.go
✅ Files skipped from review due to trivial changes (2)
  • dto/openai_image_test.go
  • relay/helper/openai_image_request_test.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • relay/channel/openai/adaptor.go
  • relay/channel/openai/image_stream_test.go
  • relay/channel/openai/relay-openai.go
  • relay/helper/valid_request.go

Comment on lines +75 to +118
// TestConvertImageEditRequestParsesReusableMultipartWhenFormIsMissing verifies fallback parsing.
func TestConvertImageEditRequestParsesReusableMultipartWhenFormIsMissing(t *testing.T) {
gin.SetMode(gin.TestMode)

var body bytes.Buffer
writer := multipart.NewWriter(&body)
require.NoError(t, writer.WriteField("model", "gpt-image-1"))
require.NoError(t, writer.WriteField("prompt", "edit without pre-parsed form"))
part, err := writer.CreateFormFile("image", "input.png")
require.NoError(t, err)
_, err = part.Write([]byte("fake image"))
require.NoError(t, err)
require.NoError(t, writer.Close())

recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
c.Request.Header.Set("Content-Type", writer.FormDataContentType())

storage, err := common.GetBodyStorage(c)
require.NoError(t, err)
c.Request.Body = io.NopCloser(storage)
c.Request.MultipartForm = nil
c.Request.PostForm = nil

info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesEdits,
}
request := dto.ImageRequest{
Model: "gpt-image-1",
Prompt: "edit without pre-parsed form",
}

converted, err := (&Adaptor{}).ConvertImageRequest(c, info, request)
require.NoError(t, err)

convertedBody, ok := converted.(*bytes.Buffer)
require.True(t, ok)
replayedRequest := httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(convertedBody.Bytes()))
replayedRequest.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
require.NoError(t, replayedRequest.ParseMultipartForm(32<<20))
require.Equal(t, "edit without pre-parsed form", replayedRequest.PostForm.Get("prompt"))
require.Len(t, replayedRequest.MultipartForm.File["image"], 1)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Cover stream in the fallback-path test too.

This test verifies reusable multipart parsing when MultipartForm is missing, but it never exercises the stream field. That leaves the main regression from this PR untested in the fallback path.

Suggested tweak
 	writer := multipart.NewWriter(&body)
 	require.NoError(t, writer.WriteField("model", "gpt-image-1"))
 	require.NoError(t, writer.WriteField("prompt", "edit without pre-parsed form"))
+	require.NoError(t, writer.WriteField("stream", "true"))
 	part, err := writer.CreateFormFile("image", "input.png")
@@
 	require.NoError(t, replayedRequest.ParseMultipartForm(32<<20))
 	require.Equal(t, "edit without pre-parsed form", replayedRequest.PostForm.Get("prompt"))
+	require.Equal(t, "true", replayedRequest.PostForm.Get("stream"))
 	require.Len(t, replayedRequest.MultipartForm.File["image"], 1)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// TestConvertImageEditRequestParsesReusableMultipartWhenFormIsMissing verifies fallback parsing.
func TestConvertImageEditRequestParsesReusableMultipartWhenFormIsMissing(t *testing.T) {
gin.SetMode(gin.TestMode)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
require.NoError(t, writer.WriteField("model", "gpt-image-1"))
require.NoError(t, writer.WriteField("prompt", "edit without pre-parsed form"))
part, err := writer.CreateFormFile("image", "input.png")
require.NoError(t, err)
_, err = part.Write([]byte("fake image"))
require.NoError(t, err)
require.NoError(t, writer.Close())
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
storage, err := common.GetBodyStorage(c)
require.NoError(t, err)
c.Request.Body = io.NopCloser(storage)
c.Request.MultipartForm = nil
c.Request.PostForm = nil
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesEdits,
}
request := dto.ImageRequest{
Model: "gpt-image-1",
Prompt: "edit without pre-parsed form",
}
converted, err := (&Adaptor{}).ConvertImageRequest(c, info, request)
require.NoError(t, err)
convertedBody, ok := converted.(*bytes.Buffer)
require.True(t, ok)
replayedRequest := httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(convertedBody.Bytes()))
replayedRequest.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
require.NoError(t, replayedRequest.ParseMultipartForm(32<<20))
require.Equal(t, "edit without pre-parsed form", replayedRequest.PostForm.Get("prompt"))
require.Len(t, replayedRequest.MultipartForm.File["image"], 1)
}
// TestConvertImageEditRequestParsesReusableMultipartWhenFormIsMissing verifies fallback parsing.
func TestConvertImageEditRequestParsesReusableMultipartWhenFormIsMissing(t *testing.T) {
gin.SetMode(gin.TestMode)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
require.NoError(t, writer.WriteField("model", "gpt-image-1"))
require.NoError(t, writer.WriteField("prompt", "edit without pre-parsed form"))
require.NoError(t, writer.WriteField("stream", "true"))
part, err := writer.CreateFormFile("image", "input.png")
require.NoError(t, err)
_, err = part.Write([]byte("fake image"))
require.NoError(t, err)
require.NoError(t, writer.Close())
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
storage, err := common.GetBodyStorage(c)
require.NoError(t, err)
c.Request.Body = io.NopCloser(storage)
c.Request.MultipartForm = nil
c.Request.PostForm = nil
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesEdits,
}
request := dto.ImageRequest{
Model: "gpt-image-1",
Prompt: "edit without pre-parsed form",
}
converted, err := (&Adaptor{}).ConvertImageRequest(c, info, request)
require.NoError(t, err)
convertedBody, ok := converted.(*bytes.Buffer)
require.True(t, ok)
replayedRequest := httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(convertedBody.Bytes()))
replayedRequest.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
require.NoError(t, replayedRequest.ParseMultipartForm(32<<20))
require.Equal(t, "edit without pre-parsed form", replayedRequest.PostForm.Get("prompt"))
require.Equal(t, "true", replayedRequest.PostForm.Get("stream"))
require.Len(t, replayedRequest.MultipartForm.File["image"], 1)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/openai/image_edit_test.go` around lines 75 - 118, The test
TestConvertImageEditRequestParsesReusableMultipartWhenFormIsMissing needs to
exercise the "stream" path as well: include the "stream" field in the multipart
body (e.g., add writer.WriteField("stream", "true")) and set the
dto.ImageRequest.Stream flag (e.g., request.Stream = true) before calling
(&Adaptor{}).ConvertImageRequest so the fallback reusable-multipart parsing
covers the stream branch.

@seefs001
Copy link
Copy Markdown
Collaborator

seefs001 commented May 5, 2026

需要考虑一下增加stream的版本和没有增加stream之间的套娃,也就是当前端按流处理但是返回的端实际上是按非流处理的情况。

@gaoren002
Copy link
Copy Markdown
Contributor Author

需要考虑一下增加stream的版本和没有增加stream之间的套娃,也就是当前端按流处理但是返回的端实际上是按非流处理的情况。

不知道这里算不算 relay/channel/openai/relay-openai.go:615 会检查 2xx + text/event-stream,否则回退 OpenaiHandlerWithUsage,不会强行包装成 SSE。

@gaoren002
Copy link
Copy Markdown
Contributor Author

@seefs001

@gaoren002
Copy link
Copy Markdown
Contributor Author

@Calcium-Ion

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenAI Images API 长耗时请求被 Cloudflare 524 切断 stream=true 未按 Images SSE 处理且缺少结果取回机制

2 participants