1515 */
1616package com .google .adk .models .langchain4j ;
1717
18+ import static java .nio .charset .StandardCharsets .UTF_8 ;
1819import static org .assertj .core .api .Assertions .*;
1920import static org .junit .jupiter .api .Assertions .assertThrows ;
2021import static org .mockito .ArgumentMatchers .any ;
2122import static org .mockito .Mockito .*;
2223
24+ import com .fasterxml .jackson .databind .JsonNode ;
2325import com .fasterxml .jackson .databind .ObjectMapper ;
2426import com .google .adk .agents .LlmAgent ;
2527import com .google .adk .agents .RunConfig ;
2830import com .google .adk .runner .InMemoryRunner ;
2931import com .google .adk .sessions .Session ;
3032import com .google .adk .tools .FunctionTool ;
33+ import com .google .adk .tools .SetModelResponseTool ;
3134import com .google .genai .types .*;
35+ import com .sun .net .httpserver .HttpServer ;
3236import dev .langchain4j .agent .tool .ToolExecutionRequest ;
3337import dev .langchain4j .data .message .AiMessage ;
3438import dev .langchain4j .data .message .UserMessage ;
3741import dev .langchain4j .model .chat .StreamingChatModel ;
3842import dev .langchain4j .model .chat .request .ChatRequest ;
3943import dev .langchain4j .model .chat .request .ChatRequestParameters ;
44+ import dev .langchain4j .model .chat .request .json .JsonAnyOfSchema ;
45+ import dev .langchain4j .model .chat .request .json .JsonArraySchema ;
4046import dev .langchain4j .model .chat .request .json .JsonObjectSchema ;
47+ import dev .langchain4j .model .chat .request .json .JsonSchemaElement ;
4148import dev .langchain4j .model .chat .request .json .JsonStringSchema ;
4249import dev .langchain4j .model .chat .response .ChatResponse ;
4350import dev .langchain4j .model .chat .response .StreamingChatResponseHandler ;
51+ import dev .langchain4j .model .googleai .GoogleAiGeminiChatModel ;
4452import dev .langchain4j .model .output .TokenUsage ;
4553import io .reactivex .rxjava3 .core .Flowable ;
4654import io .reactivex .rxjava3 .subscribers .TestSubscriber ;
55+ import java .io .OutputStream ;
56+ import java .net .InetAddress ;
57+ import java .net .InetSocketAddress ;
4758import java .util .ArrayList ;
4859import java .util .List ;
4960import java .util .Map ;
5061import java .util .Optional ;
62+ import java .util .concurrent .atomic .AtomicReference ;
5163import org .junit .jupiter .api .BeforeEach ;
5264import org .junit .jupiter .api .DisplayName ;
5365import org .junit .jupiter .api .Test ;
@@ -57,6 +69,25 @@ class LangChain4jTest {
5769
5870 private static final String MODEL_NAME = "test-model" ;
5971
72+ /**
73+ * An output schema with an untyped property, an array without items, and an untyped object with
74+ * properties.
75+ */
76+ private static final Schema FREE_FORM_SCHEMA =
77+ Schema .builder ()
78+ .type ("OBJECT" )
79+ .properties (
80+ Map .of (
81+ "extra" ,
82+ Schema .builder ().description ("Anything" ).build (),
83+ "tags" ,
84+ Schema .builder ().type ("ARRAY" ).build (),
85+ "nested" ,
86+ Schema .builder ()
87+ .properties (Map .of ("a" , Schema .builder ().type ("STRING" ).build ()))
88+ .build ()))
89+ .build ();
90+
6091 private ChatModel chatModel ;
6192 private StreamingChatModel streamingChatModel ;
6293 private LangChain4j langChain4j ;
@@ -977,6 +1008,100 @@ void testGenerateContentWithMcpToolParametersJsonSchemaAsSchema() {
9771008 assertThat (capturedRequest .toolSpecifications ().get (0 ).description ()).isEqualTo ("An MCP tool" );
9781009 }
9791010
1011+ @ Test
1012+ @ DisplayName ("Should convert free-form schemas in tool parameters" )
1013+ void testGenerateContentWithFreeFormToolParameters () {
1014+ // Given
1015+ final ChatResponse chatResponse = mock (ChatResponse .class );
1016+ when (chatResponse .aiMessage ()).thenReturn (AiMessage .from ("Done" ));
1017+ when (chatModel .chat (any (ChatRequest .class ))).thenReturn (chatResponse );
1018+
1019+ // When
1020+ final LlmResponse response =
1021+ langChain4j .generateContent (freeFormSetModelResponseRequest (), false ).blockingFirst ();
1022+
1023+ // Then
1024+ assertThat (response .content ().get ().text ()).isEqualTo ("Done" );
1025+ final ArgumentCaptor <ChatRequest > requestCaptor = ArgumentCaptor .forClass (ChatRequest .class );
1026+ verify (chatModel ).chat (requestCaptor .capture ());
1027+ final Map <String , JsonSchemaElement > properties =
1028+ requestCaptor .getValue ().toolSpecifications ().get (0 ).parameters ().properties ();
1029+ final JsonAnyOfSchema extra = (JsonAnyOfSchema ) properties .get ("extra" );
1030+ assertThat (extra .description ()).isEqualTo ("Anything" );
1031+ assertThat (extra .anyOf ()).hasSize (5 );
1032+ assertThat (((JsonArraySchema ) properties .get ("tags" )).items ())
1033+ .isInstanceOf (JsonAnyOfSchema .class );
1034+ assertThat (((JsonObjectSchema ) properties .get ("nested" )).properties ().get ("a" ))
1035+ .isInstanceOf (JsonStringSchema .class );
1036+ }
1037+
1038+ @ Test
1039+ @ DisplayName ("Should send free-form tool parameters through the Gemini provider" )
1040+ void testGenerateContentWithFreeFormToolParameters_geminiProvider () throws Exception {
1041+ // Given
1042+ final AtomicReference <String > requestBody = new AtomicReference <>();
1043+ final HttpServer server =
1044+ HttpServer .create (new InetSocketAddress (InetAddress .getLoopbackAddress (), 0 ), 0 );
1045+ server .createContext (
1046+ "/" ,
1047+ exchange -> {
1048+ requestBody .set (new String (exchange .getRequestBody ().readAllBytes (), UTF_8 ));
1049+ final byte [] reply =
1050+ ("{\" candidates\" :[{\" content\" :{\" role\" :\" model\" ,\" parts\" :[{\" text\" :\" Done\" }]},"
1051+ + "\" finishReason\" :\" STOP\" }],\" usageMetadata\" :{\" promptTokenCount\" :1,"
1052+ + "\" candidatesTokenCount\" :1,\" totalTokenCount\" :2}}" )
1053+ .getBytes (UTF_8 );
1054+ exchange .getResponseHeaders ().add ("Content-Type" , "application/json" );
1055+ exchange .sendResponseHeaders (200 , reply .length );
1056+ try (OutputStream body = exchange .getResponseBody ()) {
1057+ body .write (reply );
1058+ }
1059+ });
1060+
1061+ // When
1062+ final LlmResponse response ;
1063+ server .start ();
1064+ try {
1065+ final LangChain4j gemini =
1066+ LangChain4j .builder ()
1067+ .chatModel (
1068+ GoogleAiGeminiChatModel .builder ()
1069+ .apiKey ("test-key" )
1070+ .modelName ("gemini-2.5-flash" )
1071+ .baseUrl ("http://127.0.0.1:" + server .getAddress ().getPort ())
1072+ .maxRetries (0 )
1073+ .build ())
1074+ .build ();
1075+ response = gemini .generateContent (freeFormSetModelResponseRequest (), false ).blockingFirst ();
1076+ } finally {
1077+ server .stop (0 );
1078+ }
1079+
1080+ // Then
1081+ assertThat (response .content ().get ().text ()).isEqualTo ("Done" );
1082+ // LangChain4j 1.12 sends "tools" as one object, later versions as a list of them.
1083+ final JsonNode tools = new ObjectMapper ().readTree (requestBody .get ()).path ("tools" );
1084+ final JsonNode properties =
1085+ (tools .isArray () ? tools .path (0 ) : tools )
1086+ .at ("/functionDeclarations/0/parameters/properties" );
1087+ assertThat (properties .isObject ()).isTrue ();
1088+ assertThat (properties .at ("/extra/anyOf" ).size ()).isEqualTo (5 );
1089+ assertThat (properties .at ("/nested/properties/a/type" ).asText ()).isEqualToIgnoringCase ("string" );
1090+ // Gemini rejects an array schema without items.
1091+ assertThat (
1092+ properties .findParents ("type" ).stream ()
1093+ .filter (node -> node .get ("type" ).asText ().equalsIgnoreCase ("array" )))
1094+ .isNotEmpty ()
1095+ .allMatch (node -> node .has ("items" ));
1096+ }
1097+
1098+ private static LlmRequest freeFormSetModelResponseRequest () {
1099+ return LlmRequest .builder ()
1100+ .contents (List .of (Content .fromParts (Part .fromText ("Answer" ))))
1101+ .tools (Map .of (SetModelResponseTool .NAME , new SetModelResponseTool (FREE_FORM_SCHEMA )))
1102+ .build ();
1103+ }
1104+
9801105 @ Test
9811106 @ DisplayName (
9821107 "Should use TokenCountEstimator to estimate token usage when TokenUsage is not available" )
0 commit comments