Merge pull request #4380 from wucm667/fix/issue-4357-responses-lite-context
fix(openai): normalize Responses Lite reasoning context
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user