diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go index 945900d6d6..ade13d9826 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go @@ -280,6 +280,18 @@ func TestResponsesToChatCompletionsRequest_ToolSearchToolBecomesProxyFunction(t assert.Contains(t, string(out.Tools[0].Function.Parameters), `"query"`) } +func TestResponsesToChatCompletionsRequest_DropsDeferredFlagWithToolSearch(t *testing.T) { + var req ResponsesRequest + require.NoError(t, json.Unmarshal([]byte(`{"model":"glm-5.2","input":"hi","tools":[{"type":"tool_search"},{"type":"function","name":"shell","defer_loading":true}]}`), &req)) + + out, err := ResponsesToChatCompletionsRequest(&req) + require.NoError(t, err) + encoded, err := json.Marshal(out) + require.NoError(t, err) + require.NotContains(t, string(encoded), "defer_loading") + require.Contains(t, string(encoded), `"name":"tool_search"`) +} + // codex 只在 ResponseItem 为 tool_search_call 变体且 execution=client 时执行 // tool search;同名 function_call 会命中 ToolSearchHandler 后因 payload 不匹配 // 触发 FunctionCallError::Fatal,直接中止整个 turn,因此回程必须还原项类型。 diff --git a/backend/internal/pkg/apicompat/responses_client_tools.go b/backend/internal/pkg/apicompat/responses_client_tools.go index b7ce024e8a..b1e2c412f1 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools.go +++ b/backend/internal/pkg/apicompat/responses_client_tools.go @@ -119,6 +119,9 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, lowered = append(lowered, raw) } } + if stripResponsesDeferredToolFlags(lowered) { + changed = true + } if changed { req["tools"] = lowered } @@ -141,6 +144,26 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, return adapter, changed, nil } +// stripResponsesDeferredToolFlags removes defer_loading only when the final +// declaration list no longer contains the built-in tool_search it requires. +func stripResponsesDeferredToolFlags(tools []any) bool { + if hasResponsesToolSearchDeclaration(tools) { + return false + } + changed := false + for _, raw := range tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + if _, exists := tool["defer_loading"]; exists { + delete(tool, "defer_loading") + changed = true + } + } + return changed +} + // AdaptResponsesClientToolsWithInheritedMapping lowers client-tool history on // a follow-up request that omits the session-level tools declaration. An // explicitly present tools field, including an empty or malformed value, diff --git a/backend/internal/pkg/apicompat/responses_client_tools_test.go b/backend/internal/pkg/apicompat/responses_client_tools_test.go index 5b0dac4356..2b3460755f 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools_test.go +++ b/backend/internal/pkg/apicompat/responses_client_tools_test.go @@ -66,6 +66,33 @@ func TestAdaptResponsesClientTools_LowersDeclarationsHistoryChoiceAndNamespaces( require.Equal(t, "team__send", namespaceCall["name"]) } +func TestAdaptResponsesClientTools_RemovesDeferredFlagsWhenToolSearchIsLowered(t *testing.T) { + req := map[string]any{ + "tools": []any{ + map[string]any{"type": "tool_search"}, + map[string]any{"type": "function", "name": "shell", "defer_loading": true}, + map[string]any{"type": "function", "name": "apply_patch"}, + }, + } + + _, changed, err := AdaptResponsesClientTools(req) + require.NoError(t, err) + require.True(t, changed) + tools := requireResponsesClientToolValue[[]any](t, req["tools"]) + require.Equal(t, toolSearchProxyName, requireResponsesClientToolValue[map[string]any](t, tools[0])["name"]) + require.NotContains(t, requireResponsesClientToolValue[map[string]any](t, tools[1]), "defer_loading") +} + +func TestStripResponsesDeferredToolFlags_PreservesFlagsWithBuiltInToolSearch(t *testing.T) { + tools := []any{ + map[string]any{"type": "tool_search"}, + map[string]any{"type": "function", "name": "shell", "defer_loading": true}, + } + + require.False(t, stripResponsesDeferredToolFlags(tools)) + require.Equal(t, true, requireResponsesClientToolValue[map[string]any](t, tools[1])["defer_loading"]) +} + func TestAdaptResponsesClientTools_LowersDiscoveredToolSearchOutput(t *testing.T) { requestJSON := `{ "tools":[{"type":"tool_search"}], diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 8a5aa809b9..10a2731fbb 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -1091,6 +1091,19 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) { filteredTools = append(filteredTools, raw) } } + if !grokRawToolsContainType(filteredTools, "tool_search") { + for index, raw := range filteredTools { + if !gjson.GetBytes(raw, "defer_loading").Exists() { + continue + } + cleaned, deleteErr := sjson.DeleteBytes(raw, "defer_loading") + if deleteErr != nil { + return nil, deleteErr + } + filteredTools[index] = cleaned + toolsChanged = true + } + } var err error if len(filteredTools) != len(rawTools) || toolsChanged { @@ -1125,6 +1138,15 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) { return body, nil } +func grokRawToolsContainType(tools []json.RawMessage, want string) bool { + for _, tool := range tools { + if strings.TrimSpace(gjson.GetBytes(tool, "type").String()) == want { + return true + } + } + return false +} + func deleteGrokOrphanToolControls(body []byte) ([]byte, error) { var err error for _, field := range []string{"tool_choice", "parallel_tool_calls"} { diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 27c19866c0..e11954a4bc 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -385,6 +385,16 @@ func TestPatchGrokResponsesBodyDropsToolChoiceWhenNoSupportedToolsRemain(t *test require.False(t, gjson.GetBytes(patched, "tool_choice").Exists()) } +func TestSanitizeGrokResponsesToolsRemovesDeferredFlagsWithToolSearch(t *testing.T) { + body := []byte(`{"tools":[{"type":"tool_search"},{"type":"function","name":"shell","defer_loading":true},{"type":"function","name":"apply_patch"}]}`) + + patched, err := sanitizeGrokResponsesTools(body) + require.NoError(t, err) + require.False(t, gjson.GetBytes(patched, `tools.#(type=="tool_search")`).Exists()) + require.False(t, gjson.GetBytes(patched, `tools.#(name=="shell").defer_loading`).Exists()) + require.True(t, gjson.GetBytes(patched, `tools.#(name=="apply_patch")`).Exists()) +} + func TestSanitizeGrokResponsesToolsKeepsToolChoiceOnlyWithSupportedTools(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/openai_responses_lite_tools_test.go b/backend/internal/service/openai_responses_lite_tools_test.go index b76e97e272..f7fd7309ca 100644 --- a/backend/internal/service/openai_responses_lite_tools_test.go +++ b/backend/internal/service/openai_responses_lite_tools_test.go @@ -58,6 +58,21 @@ func TestNormalizeOpenAIResponsesLiteTools_MovesNamespacesAndKeepsSupportedTools require.Equal(t, map[string]any{"type": "namespace", "name": "collaboration"}, reqBody["tool_choice"]) } +func TestNormalizeOpenAIResponsesLiteTools_PreservesDeferredFlagsWithToolSearch(t *testing.T) { + reqBody := map[string]any{ + "tools": []any{ + map[string]any{"type": "tool_search"}, + map[string]any{"type": "function", "name": "shell", "defer_loading": true}, + }, + } + + _, err := normalizeOpenAIResponsesLiteTools(reqBody) + require.NoError(t, err) + tools := reqBody["tools"].([]any) + require.Equal(t, "tool_search", tools[0].(map[string]any)["type"]) + require.Equal(t, true, tools[1].(map[string]any)["defer_loading"]) +} + func TestNormalizeOpenAIResponsesLiteTools_RejectsConflictingAdditionalTool(t *testing.T) { reqBody := map[string]any{ "tools": []any{map[string]any{