Skip to content

Commit 7017e53

Browse files
committed
Enforce strict JSON Schema compliance to prevent OpenAI 400 Bad Request errors
1 parent 287987a commit 7017e53

3 files changed

Lines changed: 160 additions & 21 deletions

File tree

core/src/main/java/com/google/adk/models/chat/ChatCompletionsCommon.java

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ private ChatCompletionsCommon() {}
3737

3838
private static final ObjectMapper objectMapper = new ObjectMapper();
3939

40+
static final String EMPTY_JSON_OBJECT = "{}";
41+
static final Map<String, Object> EMPTY_PARAMETERS_SCHEMA =
42+
Map.of("type", "object", "properties", Map.of());
43+
4044
public static final String ROLE_ASSISTANT = "assistant";
4145
public static final String ROLE_MODEL = "model";
4246

@@ -157,6 +161,18 @@ public Part applyThoughtSignature(Part part) {
157161
}
158162
}
159163

164+
/**
165+
* Robust defense: Function arguments and responses MUST be JSON objects. If the LLM hallucinates
166+
* "null", "NULL", "none", or conversational text, we fallback to an empty JSON object "{}". This
167+
* prevents OpenAI-compatible proxies (like Groq) from throwing 400 Bad Request errors.
168+
*/
169+
static String enforceJsonObject(String json) {
170+
if (json == null || !json.trim().startsWith("{")) {
171+
return EMPTY_JSON_OBJECT;
172+
}
173+
return json.trim();
174+
}
175+
160176
/**
161177
* See
162178
* https://developers.openai.com/api/reference/resources/chat#(resource)%20chat.completions%20%3E%20(model)%20chat_completion_message_function_tool_call%20%3E%20(schema)
@@ -181,21 +197,27 @@ public FunctionCall toFunctionCall(@Nullable String toolCallId) {
181197
if (name != null) {
182198
fcBuilder.name(name);
183199
}
184-
if (arguments != null && !arguments.isEmpty()) {
185-
try {
186-
Map<String, Object> args =
187-
objectMapper.readValue(arguments, new TypeReference<Map<String, Object>>() {});
188-
fcBuilder.args(args);
189-
} catch (Exception e) {
190-
throw new IllegalArgumentException(
191-
"Failed to parse function arguments JSON: " + arguments, e);
192-
}
193-
}
200+
fcBuilder.args(parseArguments(arguments));
194201
if (toolCallId != null) {
195202
fcBuilder.id(toolCallId);
196203
}
197204
return fcBuilder.build();
198205
}
206+
207+
private Map<String, Object> parseArguments(String arguments) {
208+
if (arguments == null || arguments.trim().isEmpty()) {
209+
return Map.of();
210+
}
211+
try {
212+
String json = enforceJsonObject(arguments);
213+
Map<String, Object> result =
214+
objectMapper.readValue(json, new TypeReference<Map<String, Object>>() {});
215+
return result != null ? result : Map.of();
216+
} catch (Exception e) {
217+
throw new IllegalArgumentException(
218+
"Failed to parse function arguments JSON: " + arguments, e);
219+
}
220+
}
199221
}
200222

201223
/**

core/src/main/java/com/google/adk/models/chat/ChatCompletionsRequest.java

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,12 @@
2121
import com.fasterxml.jackson.annotation.JsonInclude;
2222
import com.fasterxml.jackson.annotation.JsonProperty;
2323
import com.fasterxml.jackson.annotation.JsonValue;
24+
import com.fasterxml.jackson.core.JsonGenerator;
2425
import com.fasterxml.jackson.core.type.TypeReference;
26+
import com.fasterxml.jackson.databind.JsonSerializer;
2527
import com.fasterxml.jackson.databind.ObjectMapper;
28+
import com.fasterxml.jackson.databind.SerializerProvider;
29+
import com.fasterxml.jackson.databind.module.SimpleModule;
2630
import com.google.adk.JsonBaseModel;
2731
import com.google.adk.models.LlmRequest;
2832
import com.google.common.collect.ImmutableList;
@@ -32,6 +36,8 @@
3236
import com.google.genai.types.FunctionResponse;
3337
import com.google.genai.types.GenerateContentConfig;
3438
import com.google.genai.types.Part;
39+
import com.google.genai.types.Type;
40+
import java.io.IOException;
3541
import java.util.ArrayList;
3642
import java.util.Base64;
3743
import java.util.List;
@@ -270,7 +276,28 @@ public final class ChatCompletionsRequest {
270276
public Map<String, Object> extraBody;
271277

272278
private static final Logger logger = LoggerFactory.getLogger(ChatCompletionsRequest.class);
273-
private static final ObjectMapper objectMapper = JsonBaseModel.getMapper();
279+
280+
/**
281+
* Registers a custom serializer to force JSON Schema types to lowercase (e.g., "STRING" ->
282+
* "string"). The genai SDK uses uppercase Enums for schema types, which strict OpenAI-compatible
283+
* endpoints reject with HTTP 400.
284+
*/
285+
private static SimpleModule schemaNormalizerModule() {
286+
SimpleModule module = new SimpleModule();
287+
module.addSerializer(
288+
Type.class,
289+
new JsonSerializer<Type>() {
290+
@Override
291+
public void serialize(Type value, JsonGenerator gen, SerializerProvider serializers)
292+
throws IOException {
293+
gen.writeString(value.toString().toLowerCase());
294+
}
295+
});
296+
return module;
297+
}
298+
299+
private static final ObjectMapper objectMapper =
300+
JsonBaseModel.getMapper().copy().registerModule(schemaNormalizerModule());
274301

275302
/**
276303
* Converts a standard {@link LlmRequest} into a {@link ChatCompletionsRequest} for
@@ -473,10 +500,14 @@ private static ChatCompletionsCommon.ToolCall processFunctionCallPart(Part part)
473500
function.name = fc.name().orElse("");
474501
if (fc.args().isPresent()) {
475502
try {
476-
function.arguments = objectMapper.writeValueAsString(fc.args().get());
503+
String json = objectMapper.writeValueAsString(fc.args().get());
504+
function.arguments = ChatCompletionsCommon.enforceJsonObject(json);
477505
} catch (Exception e) {
478506
logger.warn("Failed to serialize function arguments", e);
507+
function.arguments = ChatCompletionsCommon.EMPTY_JSON_OBJECT;
479508
}
509+
} else {
510+
function.arguments = ChatCompletionsCommon.EMPTY_JSON_OBJECT;
480511
}
481512
toolCall.function = function;
482513
part.thoughtSignature()
@@ -502,10 +533,14 @@ private static Message processFunctionResponsePart(Part part) {
502533
toolResp.toolCallId = fr.id().orElse("");
503534
if (fr.response().isPresent()) {
504535
try {
505-
toolResp.content = new MessageContent(objectMapper.writeValueAsString(fr.response().get()));
536+
String json = objectMapper.writeValueAsString(fr.response().get());
537+
toolResp.content = new MessageContent(ChatCompletionsCommon.enforceJsonObject(json));
506538
} catch (Exception e) {
507539
logger.warn("Failed to serialize tool response", e);
540+
toolResp.content = new MessageContent(ChatCompletionsCommon.EMPTY_JSON_OBJECT);
508541
}
542+
} else {
543+
toolResp.content = new MessageContent(ChatCompletionsCommon.EMPTY_JSON_OBJECT);
509544
}
510545
return toolResp;
511546
}
@@ -570,12 +605,15 @@ private static void handleTools(GenerateContentConfig config, ChatCompletionsReq
570605
FunctionDefinition def = new FunctionDefinition();
571606
def.name = fd.name().orElse("");
572607
def.description = fd.description().orElse("");
573-
fd.parameters()
574-
.ifPresent(
575-
params ->
576-
def.parameters =
577-
objectMapper.convertValue(
578-
params, new TypeReference<Map<String, Object>>() {}));
608+
if (fd.parameters().isPresent()) {
609+
def.parameters =
610+
objectMapper.convertValue(
611+
fd.parameters().get(), new TypeReference<Map<String, Object>>() {});
612+
} else {
613+
// OpenAI-compatible APIs (like Groq) strictly require the parameters object
614+
// to exist, even for zero-argument functions.
615+
def.parameters = ChatCompletionsCommon.EMPTY_PARAMETERS_SCHEMA;
616+
}
579617
tool.function = def;
580618
tools.add(tool);
581619
}

core/src/test/java/com/google/adk/models/chat/ChatCompletionsRequestTest.java

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import com.google.genai.types.FunctionResponse;
3535
import com.google.genai.types.GenerateContentConfig;
3636
import com.google.genai.types.Part;
37+
import com.google.genai.types.Schema;
3738
import com.google.genai.types.Tool;
3839
import com.google.genai.types.ToolConfig;
3940
import java.util.AbstractMap;
@@ -567,6 +568,84 @@ public void testFromLlmRequest_withFunctionCall() throws Exception {
567568
assertThat(msg.toolCalls.get(0).function.arguments).isEqualTo("{\"location\":\"Paris\"}");
568569
}
569570

571+
@Test
572+
public void testFromLlmRequest_withAbsentFunctionArguments() throws Exception {
573+
FunctionCall functionCall = FunctionCall.builder().id("call_123").name("get_time").build();
574+
Part part = Part.builder().functionCall(functionCall).build();
575+
Content content = Content.builder().role("model").parts(ImmutableList.of(part)).build();
576+
577+
LlmRequest llmRequest =
578+
LlmRequest.builder().model("gemini-1.5-pro").contents(ImmutableList.of(content)).build();
579+
580+
ChatCompletionsRequest request = ChatCompletionsRequest.fromLlmRequest(llmRequest, false);
581+
582+
assertThat(request.messages).hasSize(1);
583+
ChatCompletionsRequest.Message msg = request.messages.get(0);
584+
assertThat(msg.role).isEqualTo("assistant");
585+
assertThat(msg.toolCalls).hasSize(1);
586+
assertThat(msg.toolCalls.get(0).function.name).isEqualTo("get_time");
587+
assertThat(msg.toolCalls.get(0).function.arguments).isEqualTo("{}");
588+
}
589+
590+
@Test
591+
public void testFromLlmRequest_withAbsentParameters() throws Exception {
592+
FunctionDeclaration function =
593+
FunctionDeclaration.builder().name("test_func").description("A test function").build();
594+
595+
Tool tool = Tool.builder().functionDeclarations(ImmutableList.of(function)).build();
596+
GenerateContentConfig config =
597+
GenerateContentConfig.builder().tools(ImmutableList.of(tool)).build();
598+
599+
LlmRequest llmRequest =
600+
LlmRequest.builder()
601+
.model("gemini-1.5-pro")
602+
.config(config)
603+
.contents(ImmutableList.of())
604+
.build();
605+
606+
ChatCompletionsRequest request = ChatCompletionsRequest.fromLlmRequest(llmRequest, false);
607+
608+
assertThat(request.tools).hasSize(1);
609+
Map<String, Object> params = (Map<String, Object>) request.tools.get(0).function.parameters;
610+
assertThat(params.get("type")).isEqualTo("object");
611+
@SuppressWarnings("unchecked")
612+
Map<String, Object> props = (Map<String, Object>) params.get("properties");
613+
assertThat(props).isEmpty();
614+
}
615+
616+
@Test
617+
public void testFromLlmRequest_normalizesSchemaTypeToLowerCase() throws Exception {
618+
Schema param1Schema = Schema.builder().type("STRING").build();
619+
620+
Schema functionSchema =
621+
Schema.builder().type("OBJECT").properties(ImmutableMap.of("param1", param1Schema)).build();
622+
623+
FunctionDeclaration function =
624+
FunctionDeclaration.builder().name("test_func").parameters(functionSchema).build();
625+
626+
Tool tool = Tool.builder().functionDeclarations(ImmutableList.of(function)).build();
627+
GenerateContentConfig config =
628+
GenerateContentConfig.builder().tools(ImmutableList.of(tool)).build();
629+
630+
LlmRequest llmRequest =
631+
LlmRequest.builder()
632+
.model("gemini-1.5-pro")
633+
.config(config)
634+
.contents(ImmutableList.of())
635+
.build();
636+
637+
ChatCompletionsRequest request = ChatCompletionsRequest.fromLlmRequest(llmRequest, false);
638+
639+
assertThat(request.tools).hasSize(1);
640+
Map<String, Object> params = (Map<String, Object>) request.tools.get(0).function.parameters;
641+
assertThat(params.get("type")).isEqualTo("object");
642+
@SuppressWarnings("unchecked")
643+
Map<String, Object> props = (Map<String, Object>) params.get("properties");
644+
@SuppressWarnings("unchecked")
645+
Map<String, Object> param1 = (Map<String, Object>) props.get("param1");
646+
assertThat(param1.get("type")).isEqualTo("string");
647+
}
648+
570649
@Test
571650
public void testFromLlmRequest_withStreamOptions() throws Exception {
572651
LlmRequest llmRequest =
@@ -628,11 +707,11 @@ public void testFromLlmRequest_withFunctionResponse() throws Exception {
628707

629708
assertThat(request.messages.get(1).role).isEqualTo("tool");
630709
assertThat(request.messages.get(1).toolCallId).isEmpty();
631-
assertThat(request.messages.get(1).content).isNull();
710+
assertThat(request.messages.get(1).content.getValue()).isEqualTo("{}");
632711

633712
assertThat(request.messages.get(2).role).isEqualTo("tool");
634713
assertThat(request.messages.get(2).toolCallId).isEqualTo("call_faulty");
635-
assertThat(request.messages.get(2).content).isNull();
714+
assertThat(request.messages.get(2).content.getValue()).isEqualTo("{}");
636715
}
637716

638717
@Test

0 commit comments

Comments
 (0)