Merge pull request #4380 from wucm667/fix/issue-4357-responses-lite-context

fix(openai): normalize Responses Lite reasoning context
This commit is contained in:
Wesley Liddick
2026-07-16 09:31:29 +08:00
committed by GitHub
3 changed files with 83 additions and 7 deletions
@@ -7,18 +7,23 @@ import (
"strings"
)
// normalizeOpenAIResponsesLiteTools converts private namespace declarations
// into the input.additional_tools carrier required by Responses Lite. Other
// top-level tools must belong to the small set accepted by the Lite endpoint;
// rejecting unsupported hosted tools is intentional because silently dropping
// them would change the client's requested behavior.
// normalizeOpenAIResponsesLiteTools applies the Responses Lite request
// contract: reasoning must cover all turns, and private namespace declarations
// use the input.additional_tools carrier. Other top-level tools must belong to
// the small set accepted by the Lite endpoint; rejecting unsupported hosted
// tools is intentional because silently dropping them would change behavior.
func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) {
if reqBody == nil {
return false, nil
}
if rawReasoning, exists := reqBody["reasoning"]; exists && rawReasoning != nil {
if _, ok := rawReasoning.(map[string]any); !ok {
return false, fmt.Errorf("responses Lite requires reasoning to be an object")
}
}
rawTools, exists := reqBody["tools"]
if !exists || rawTools == nil {
return false, nil
return ensureOpenAIResponsesLiteReasoningContext(reqBody)
}
tools, ok := rawTools.([]any)
if !ok {
@@ -52,13 +57,16 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) {
}
}
if len(namespaceTools) == 0 {
return false, nil
return ensureOpenAIResponsesLiteReasoningContext(reqBody)
}
input, err := appendOpenAIResponsesLiteAdditionalTools(reqBody["input"], namespaceTools)
if err != nil {
return false, err
}
if _, err := ensureOpenAIResponsesLiteReasoningContext(reqBody); err != nil {
return false, err
}
reqBody["input"] = input
if len(topLevelTools) == 0 {
delete(reqBody, "tools")
@@ -68,6 +76,23 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) {
return true, nil
}
func ensureOpenAIResponsesLiteReasoningContext(reqBody map[string]any) (bool, error) {
rawReasoning, exists := reqBody["reasoning"]
if !exists || rawReasoning == nil {
reqBody["reasoning"] = map[string]any{"context": "all_turns"}
return true, nil
}
reasoning, ok := rawReasoning.(map[string]any)
if !ok {
return false, fmt.Errorf("responses Lite requires reasoning to be an object")
}
if context, ok := reasoning["context"].(string); ok && context == "all_turns" {
return false, nil
}
reasoning["context"] = "all_turns"
return true, nil
}
func appendOpenAIResponsesLiteAdditionalTools(input any, namespaceTools []any) ([]any, error) {
var items []any
switch typed := input.(type) {
@@ -135,6 +135,7 @@ func TestNormalizeOpenAIResponsesLiteTools_ConvertsStringInput(t *testing.T) {
func TestNormalizeOpenAIResponsesLiteTools_KeepsSupportedTopLevelTools(t *testing.T) {
reqBody := map[string]any{
"reasoning": map[string]any{"context": "all_turns"},
"tools": []any{
map[string]any{"type": "function", "name": "shell"},
map[string]any{"type": "custom", "name": "exec"},
@@ -150,6 +151,46 @@ func TestNormalizeOpenAIResponsesLiteTools_KeepsSupportedTopLevelTools(t *testin
require.Len(t, reqBody["tools"], 4)
}
func TestNormalizeOpenAIResponsesLiteTools_EnsuresReasoningContext(t *testing.T) {
tests := []struct {
name string
reasoning any
}{
{name: "missing"},
{name: "missing context", reasoning: map[string]any{"effort": "high"}},
{name: "wrong context", reasoning: map[string]any{"effort": "medium", "context": "current_turn"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reqBody := map[string]any{"input": "hello"}
if tt.reasoning != nil {
reqBody["reasoning"] = tt.reasoning
}
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
require.NoError(t, err)
require.True(t, changed)
reasoning := reqBody["reasoning"].(map[string]any)
require.Equal(t, "all_turns", reasoning["context"])
if tt.name != "missing" {
require.Equal(t, tt.reasoning.(map[string]any)["effort"], reasoning["effort"])
}
})
}
}
func TestNormalizeOpenAIResponsesLiteTools_RejectsNonObjectReasoning(t *testing.T) {
reqBody := map[string]any{"reasoning": "high"}
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
require.ErrorContains(t, err, "reasoning to be an object")
require.False(t, changed)
require.Equal(t, "high", reqBody["reasoning"])
}
func TestNormalizeOpenAIResponsesLiteTools_RejectsUnsupportedTools(t *testing.T) {
tests := []struct {
name string
@@ -240,6 +281,7 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes
}
body := []byte(`{
"model":"gpt-5.6-terra","stream":true,"instructions":"test",
"reasoning":{"effort":"high","context":"current_turn"},
"tools":[
{"type":"function","name":"shell","parameters":{"type":"object"}},
{"type":"custom","name":"exec"},
@@ -255,6 +297,8 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader))
require.Equal(t, "high", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
require.Equal(t, "all_turns", gjson.GetBytes(upstream.lastBody, "reasoning.context").String())
require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="namespace")`).Exists())
require.Equal(t, "shell", gjson.GetBytes(upstream.lastBody, `tools.#(type=="function").name`).String())
require.Equal(t, "exec", gjson.GetBytes(upstream.lastBody, `tools.#(type=="custom").name`).String())
@@ -531,6 +531,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge
"model":"gpt-5.5",
"stream":false,
"previous_response_id":"resp_codex_image_bridge",
"reasoning":{"effort":"high"},
"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},
"tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}],
"input":[
@@ -583,6 +584,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge
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")
require.False(t, gjson.Get(nonLitePayload, "reasoning.context").Exists())
litePayload := requestToJSONString(captureConn.writes[1])
require.False(t, gjson.Get(litePayload, `tools.#(type=="image_generation")`).Exists())
@@ -593,6 +595,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge
require.Equal(t, "collaboration", gjson.Get(litePayload, `input.#(type=="additional_tools").tools.1.name`).String())
require.Equal(t, "namespace", gjson.Get(litePayload, "tool_choice.type").String())
require.Equal(t, "collaboration", gjson.Get(litePayload, "tool_choice.name").String())
require.Equal(t, "high", gjson.Get(litePayload, "reasoning.effort").String())
require.Equal(t, "all_turns", gjson.Get(litePayload, "reasoning.context").String())
functionPayload := requestToJSONString(captureConn.writes[2])
require.True(t, gjson.Get(functionPayload, `tools.#(name=="image_gen.imagegen")`).Exists())
@@ -976,6 +980,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade
"model":"gpt-5.1",
"stream":false,
"prompt_cache_key":"pcache_passthrough",
"reasoning":{"effort":"medium","context":"current_turn"},
"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},
"tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}],
"input":[{"type":"message","role":"user","content":"hello"}],
@@ -1009,6 +1014,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade
require.Equal(t, "collaboration", gjson.Get(forwarded, `input.#(type=="additional_tools").tools.0.name`).String())
require.Equal(t, "namespace", gjson.Get(forwarded, "tool_choice.type").String())
require.Equal(t, "collaboration", gjson.Get(forwarded, "tool_choice.name").String())
require.Equal(t, "medium", gjson.Get(forwarded, "reasoning.effort").String())
require.Equal(t, "all_turns", gjson.Get(forwarded, "reasoning.context").String())
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRelaysHTTPStream(t *testing.T) {