mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-22 06:40:21 +08:00
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:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user