diff --git a/backend/internal/service/openai_responses_lite_tools.go b/backend/internal/service/openai_responses_lite_tools.go index a15bc498b..c5c16ecaa 100644 --- a/backend/internal/service/openai_responses_lite_tools.go +++ b/backend/internal/service/openai_responses_lite_tools.go @@ -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) { diff --git a/backend/internal/service/openai_responses_lite_tools_test.go b/backend/internal/service/openai_responses_lite_tools_test.go index aa0395446..b76e97e27 100644 --- a/backend/internal/service/openai_responses_lite_tools_test.go +++ b/backend/internal/service/openai_responses_lite_tools_test.go @@ -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()) diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go index 3752c1bd0..2578c623d 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -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) {