Merge pull request #4240 from chensunlai/fix/openai-responses-lite-image-display

fix(openai): preserve client image tools in Responses Lite
This commit is contained in:
Wesley Liddick
2026-07-14 14:22:50 +08:00
committed by GitHub
8 changed files with 96 additions and 10 deletions
@@ -9,9 +9,23 @@ import (
const (
openAIResponsesEndpoint = "/v1/responses"
openAIResponsesCompactEndpoint = "/v1/responses/compact"
responsesLiteHeader = "X-OpenAI-Internal-Codex-Responses-Lite"
responsesLiteHeaderKey = "x-openai-internal-codex-responses-lite"
responsesLiteWSMetadataKey = "ws_request_header_x_openai_internal_codex_responses_lite"
imageGenerationPermissionMessage = "Image generation is not enabled for this group"
)
func isOpenAIResponsesLiteHeader(value string) bool {
return strings.EqualFold(strings.TrimSpace(value), "true")
}
func isOpenAIResponsesLiteWebSocketPayload(body []byte) bool {
if len(body) == 0 || !gjson.ValidBytes(body) {
return false
}
return isOpenAIResponsesLiteHeader(gjson.GetBytes(body, "client_metadata."+responsesLiteWSMetadataKey).String())
}
// ImageGenerationPermissionMessage returns the stable end-user error text for disabled groups.
func ImageGenerationPermissionMessage() string {
return imageGenerationPermissionMessage
@@ -173,7 +173,11 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if apiKey != nil {
imageGenerationAllowed = GroupAllowsImageGeneration(apiKey.Group)
}
codexImageGenerationBridgeEnabled := isCodexCLI && imageGenerationAllowed && codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey)
codexImageGenerationBridgeEnabled := isCodexCLI &&
!isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) &&
imageGenerationAllowed &&
codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip &&
s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey)
var imageIntent bool
if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip {
decoded, decodeErr := ensureReqBody()
@@ -70,6 +70,7 @@ var openaiAllowedHeaders = map[string]bool{
"x-codex-beta-features": true,
"x-codex-turn-state": true,
"x-codex-turn-metadata": true,
responsesLiteHeaderKey: true,
}
// OpenAI passthrough allowed headers whitelist.
@@ -86,6 +87,7 @@ var openaiPassthroughAllowedHeaders = map[string]bool{
"x-codex-beta-features": true,
"x-codex-turn-state": true,
"x-codex-turn-metadata": true,
responsesLiteHeaderKey: true,
}
// codex_cli_only 拒绝时记录的请求头白名单(仅用于诊断日志,不参与上游透传)
@@ -87,11 +87,13 @@ func TestOpenAIGatewayServiceForward_CodexImageInjectionRespectsGroupCapability(
name string
allowImages bool
bridgeEnabled bool
responsesLite bool
wantInjected bool
}{
{name: "disabled group skips injection", allowImages: false, bridgeEnabled: true, wantInjected: false},
{name: "enabled group skips injection by default", allowImages: true, bridgeEnabled: false, wantInjected: false},
{name: "enabled group injects image tool when bridge enabled", allowImages: true, bridgeEnabled: true, wantInjected: true},
{name: "responses lite skips hosted image bridge", allowImages: true, bridgeEnabled: true, responsesLite: true, wantInjected: false},
}
for _, tt := range tests {
@@ -106,6 +108,9 @@ func TestOpenAIGatewayServiceForward_CodexImageInjectionRespectsGroupCapability(
svc := newOpenAIImageGenerationControlTestService(upstream)
svc.cfg.Gateway.CodexImageGenerationBridgeEnabled = tt.bridgeEnabled
c, _ := newOpenAIImageGenerationControlTestContext(tt.allowImages, "codex_cli_rs/0.98.0")
if tt.responsesLite {
c.Request.Header.Set(responsesLiteHeader, "true")
}
account := newOpenAIImageGenerationControlTestAccount()
result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.4","input":"write code","stream":false}`))
@@ -115,6 +120,11 @@ func TestOpenAIGatewayServiceForward_CodexImageInjectionRespectsGroupCapability(
require.NotNil(t, upstream.lastReq)
hasImageTool := gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation")`).Exists()
require.Equal(t, tt.wantInjected, hasImageTool)
expectedLiteHeader := ""
if tt.responsesLite {
expectedLiteHeader = "true"
}
require.Equal(t, expectedLiteHeader, upstream.lastReq.Header.Get(responsesLiteHeader))
instructions := gjson.GetBytes(upstream.lastBody, "instructions").String()
require.Equal(t, tt.wantInjected, strings.Contains(instructions, "image_generation"))
toolChoice := gjson.GetBytes(upstream.lastBody, "tool_choice")
@@ -126,6 +136,24 @@ func TestOpenAIGatewayServiceForward_CodexImageInjectionRespectsGroupCapability(
}
}
func TestOpenAIBuildUpstreamRequestOpenAIPassthroughForwardsResponsesLiteHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.98.0")
c.Request.Header.Set(responsesLiteHeader, "true")
svc := newOpenAIImageGenerationControlTestService(&httpUpstreamRecorder{})
req, err := svc.buildUpstreamRequestOpenAIPassthrough(
c.Request.Context(),
c,
newOpenAIImageGenerationControlTestAccount(),
[]byte(`{"model":"gpt-5.4","input":"write code"}`),
"test-token",
)
require.NoError(t, err)
require.Equal(t, "true", req.Header.Get(responsesLiteHeader))
}
func TestOpenAIGatewayServiceForward_ExplicitImageToolWorksWithBridgeDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -238,7 +238,11 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
if isCodexCLI {
codexImageGenerationExplicitToolPolicy = account.CodexImageGenerationExplicitToolPolicy()
}
codexBridgeEnabled := isCodexCLI && imageGenerationAllowed && codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey)
codexBridgeEnabled := isCodexCLI &&
!isOpenAIResponsesLiteWebSocketPayload(normalized) &&
imageGenerationAllowed &&
codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip &&
s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey)
if codexBridgeEnabled {
payloadMap := make(map[string]any)
if err := json.Unmarshal(normalized, &payloadMap); err != nil {
@@ -403,7 +403,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_FollowupCreateCa
require.Equal(t, "resp_omit_model_1", gjson.Get(requestToJSONString(captureConn.writes[1]), "previous_response_id").String())
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_InjectsCodexImageBridge(t *testing.T) {
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridgeRespectsResponsesLite(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := &config.Config{}
@@ -424,6 +424,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_InjectsCodexImag
captureConn := &openAIWSCaptureConn{
events: [][]byte{
[]byte(`{"type":"response.completed","response":{"id":"resp_codex_image_bridge","model":"gpt-5.5","usage":{"input_tokens":1,"output_tokens":1}}}`),
[]byte(`{"type":"response.completed","response":{"id":"resp_codex_image_lite","model":"gpt-5.5","usage":{"input_tokens":1,"output_tokens":1}}}`),
},
}
captureDialer := &openAIWSCaptureDialer{conn: captureConn}
@@ -523,6 +524,28 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_InjectsCodexImag
require.Equal(t, coderws.MessageText, msgType)
require.Equal(t, "resp_codex_image_bridge", gjson.GetBytes(message, "response.id").String())
writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second)
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{
"type":"response.create",
"model":"gpt-5.5",
"stream":false,
"previous_response_id":"resp_codex_image_bridge",
"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},
"input":[
{"type":"additional_tools","role":"developer","tools":[{"type":"custom","name":"exec","description":"Execute code-mode tools, including image_gen.imagegen."}]},
{"type":"message","role":"user","content":[{"type":"input_text","text":"draw a cat"}]}
]
}`))
cancelWrite()
require.NoError(t, err)
readCtx, cancelRead = context.WithTimeout(context.Background(), 3*time.Second)
msgType, message, err = clientConn.Read(readCtx)
cancelRead()
require.NoError(t, err)
require.Equal(t, coderws.MessageText, msgType)
require.Equal(t, "resp_codex_image_lite", gjson.GetBytes(message, "response.id").String())
_ = clientConn.Close(coderws.StatusNormalClosure, "done")
select {
@@ -532,12 +555,19 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_InjectsCodexImag
t.Fatal("等待 ingress websocket 结束超时")
}
require.Len(t, captureConn.writes, 1)
upstreamPayload := requestToJSONString(captureConn.writes[0])
require.True(t, gjson.Get(upstreamPayload, `tools.#(type=="image_generation")`).Exists())
require.Equal(t, "png", gjson.Get(upstreamPayload, `tools.#(type=="image_generation").output_format`).String())
require.Equal(t, "auto", gjson.Get(upstreamPayload, "tool_choice").String())
require.Contains(t, gjson.Get(upstreamPayload, "instructions").String(), "image_generation")
require.Len(t, captureConn.writes, 2)
nonLitePayload := requestToJSONString(captureConn.writes[0])
require.True(t, gjson.Get(nonLitePayload, `tools.#(type=="image_generation")`).Exists())
require.Equal(t, "png", gjson.Get(nonLitePayload, `tools.#(type=="image_generation").output_format`).String())
require.Equal(t, "auto", gjson.Get(nonLitePayload, "tool_choice").String())
require.Contains(t, gjson.Get(nonLitePayload, "instructions").String(), "image_generation")
litePayload := requestToJSONString(captureConn.writes[1])
require.False(t, gjson.Get(litePayload, `tools.#(type=="image_generation")`).Exists())
require.False(t, gjson.Get(litePayload, "tool_choice").Exists())
require.NotContains(t, gjson.Get(litePayload, "instructions").String(), "image_generation")
require.Equal(t, "exec", gjson.Get(litePayload, `input.#(type=="additional_tools").tools.0.name`).String())
require.Contains(t, gjson.Get(litePayload, `input.#(type=="additional_tools").tools.0.description`).String(), "image_gen.imagegen")
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_DedicatedModeDoesNotReuseConnAcrossSessions(t *testing.T) {
@@ -201,6 +201,9 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
if err != nil {
return nil, err
}
if account.Platform != PlatformGrok && isOpenAIResponsesLiteWebSocketPayload(payload) {
upstreamReq.Header.Set(responsesLiteHeader, "true")
}
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
@@ -91,7 +91,7 @@ func TestOpenAIWSHTTPBridgeRelaysSSEFramesAsWebSocketMessages(t *testing.T) {
Concurrency: 1,
Status: StatusActive,
}
payload := []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":true,"input":"hi"}`)
payload := []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":true,"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},"input":"hi"}`)
type bridgeResult struct {
result *OpenAIForwardResult
@@ -173,6 +173,7 @@ func TestOpenAIWSHTTPBridgeRelaysSSEFramesAsWebSocketMessages(t *testing.T) {
require.NotNil(t, upstream.lastReq)
require.Equal(t, http.MethodPost, upstream.lastReq.Method)
require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader))
require.False(t, gjson.GetBytes(upstream.lastBody, "type").Exists())
require.False(t, gjson.GetBytes(upstream.lastBody, "generate").Exists())
require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())