diff --git a/src/main/java/com/google/genai/Common.java b/src/main/java/com/google/genai/Common.java
index df84ea9ae02..3747034dd9f 100644
--- a/src/main/java/com/google/genai/Common.java
+++ b/src/main/java/com/google/genai/Common.java
@@ -16,27 +16,212 @@
package com.google.genai;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.StreamReadConstraints;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.google.api.core.InternalApi;
+import com.google.common.base.Strings;
import com.google.genai.errors.GenAiIOException;
import com.google.genai.types.HttpOptions;
+import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import java.util.StringJoiner;
+import java.util.logging.Logger;
import org.jspecify.annotations.Nullable;
-/** Common utility methods for the GenAI SDK. */
+/**
+ * Common utility methods for the GenAI SDK.
+ *
+ *
All utility methods in this class are for internal use only and are subject to change without
+ * notice.
+ */
@InternalApi
public final class Common {
+ @InternalApi public static final ObjectMapper objectMapper = new ObjectMapper();
+ private static final Logger logger = Logger.getLogger(Common.class.getName());
+
+ /**
+ * System property to override the default max JSON string length (20MB) in read constraints.
+ * E.g., if you want to change the limit to 100MB, you can set it via
+ * `-Dgenai.json.maxReadLength=100000000`.
+ */
+ public static final String MAX_READ_LENGTH_PROPERTY = "genai.json.maxReadLength";
+
+ /** Custom Jackson serializer for {@link Duration} to output "Xs" format. */
+ @SuppressWarnings("JavaDurationGetSecondsToToSeconds")
+ public static class CustomDurationSerializer extends JsonSerializer {
+ public CustomDurationSerializer() {}
+
+ @Override
+ public void serialize(
+ Duration duration, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
+ throws IOException {
+ if (duration == null) {
+ jsonGenerator.writeNull();
+ } else {
+ jsonGenerator.writeString(duration.getSeconds() + "s");
+ }
+ }
+ }
+
+ /** Custom Jackson deserializer for {@link Duration} to parse "Xs" format. */
+ public static class CustomDurationDeserializer extends JsonDeserializer {
+ public CustomDurationDeserializer() {}
+
+ @Override
+ public @Nullable Duration deserialize(JsonParser p, DeserializationContext ctxt)
+ throws IOException {
+ String value = p.getValueAsString();
+
+ if (Strings.isNullOrEmpty(value)) {
+ return null;
+ }
+ if (value.endsWith("s")) {
+ String secondsPart = value.substring(0, value.length() - 1);
+ try {
+ long seconds = Long.parseLong(secondsPart);
+ return Duration.ofSeconds(seconds);
+ } catch (NumberFormatException e) {
+ JsonMappingException exception =
+ ctxt.weirdStringException(
+ value,
+ Duration.class,
+ "Cannot parse duration from string: " + value + ". Expected format 'Xs'.");
+ exception.initCause(e);
+ throw exception;
+ }
+ } else {
+ throw ctxt.weirdStringException(
+ value, Duration.class, "Expected duration in format 'Xs', but got: " + value);
+ }
+ }
+ }
+
+ /** Configures the stream read constraints for the JSON parser. */
+ private static void configureStreamReadConstraints(int maxReadLength) {
+ if (maxReadLength <= 0) {
+ throw new IllegalArgumentException("Invalid JSON max read length: " + maxReadLength);
+ }
+ logger.info("Setting Jackson max read length to " + maxReadLength);
+
+ StreamReadConstraints streamReadConstraints =
+ StreamReadConstraints.builder().maxStringLength(maxReadLength).build();
+
+ objectMapper.getFactory().setStreamReadConstraints(streamReadConstraints);
+ }
+
+ static {
+ // Configure default stream read constraints.
+ int maxReadLength = 20000000; // 20MB
+ String maxReadLengthString = System.getProperty(MAX_READ_LENGTH_PROPERTY);
+ if (maxReadLengthString != null) {
+ try {
+ maxReadLength = Integer.parseInt(maxReadLengthString);
+ } catch (NumberFormatException e) {
+ logger.warning(
+ "Invalid JSON max read length in property "
+ + MAX_READ_LENGTH_PROPERTY
+ + ": "
+ + maxReadLengthString
+ + ". Using default value: "
+ + maxReadLength);
+ }
+ }
+ configureStreamReadConstraints(maxReadLength);
+
+ objectMapper.setSerializationInclusion(JsonInclude.Include.NON_ABSENT);
+ objectMapper.registerModule(new Jdk8Module());
+ objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+
+ SimpleModule durationModule = new SimpleModule();
+ durationModule.addSerializer(Duration.class, new CustomDurationSerializer());
+ durationModule.addDeserializer(Duration.class, new CustomDurationDeserializer());
+
+ objectMapper.registerModule(new JavaTimeModule());
+ objectMapper.registerModule(durationModule);
+ }
+
+ /** Sets the maximum allowed string length for Jackson JSON parsing. */
+ public static void setMaxReadLength(int maxReadLength) {
+ configureStreamReadConstraints(maxReadLength);
+ }
+
+ /** Returns the shared {@link ObjectMapper} instance used by the SDK. */
+ public static ObjectMapper objectMapper() {
+ return objectMapper;
+ }
+
+ /** Serializes the given object into a JSON string. */
+ public static String toJsonString(Object object) {
+ try {
+ return objectMapper.writeValueAsString(object);
+ } catch (JsonProcessingException e) {
+ throw new GenAiIOException("Failed to serialize the object to JSON.", e);
+ }
+ }
+
+ /** Serializes the given object into a Jackson {@link JsonNode}. */
+ public static JsonNode toJsonNode(Object object) {
+ return objectMapper.valueToTree(object);
+ }
+
+ /** Deserializes the given JSON string into an instance of {@code clazz}. */
+ public static T fromJsonString(String jsonString, Class clazz) {
+ try {
+ return objectMapper.readValue(jsonString, clazz);
+ } catch (JsonProcessingException e) {
+ throw new GenAiIOException("Failed to deserialize the JSON string.", e);
+ }
+ }
+
+ /** Deserializes the given Jackson {@link JsonNode} into an instance of {@code clazz}. */
+ public static T fromJsonNode(JsonNode jsonNode, Class clazz) {
+ try {
+ return objectMapper.treeToValue(jsonNode, clazz);
+ } catch (JsonProcessingException e) {
+ throw new GenAiIOException("Failed to deserialize the JSON node.", e);
+ }
+ }
+
+ /** Parses the given JSON string into a Jackson {@link JsonNode}. */
+ public static JsonNode stringToJsonNode(String string) {
+ try {
+ return objectMapper.readTree(string);
+ } catch (JsonProcessingException e) {
+ throw new GenAiIOException("Failed to parse the JSON string.", e);
+ }
+ }
+
private Common() {}
/** A class that holds the path, body, and http options of an API request. */
@@ -136,8 +321,40 @@ public static void setValueByPath(ObjectNode jsonObject, String[] path, Object v
ObjectNode sourceNode = (ObjectNode) value;
currentObject.setAll(sourceNode);
} else {
- JsonNode valueNode = JsonSerializable.toJsonNode(value);
- Transformers.updateJsonNode(currentObject, keyToSet, valueNode);
+ JsonNode valueNode = toJsonNode(value);
+ updateJsonNode(currentObject, keyToSet, valueNode);
+ }
+ }
+
+ /** Updates an ObjectNode with a key and value, merging objects or avoiding empty overwrites. */
+ public static void updateJsonNode(ObjectNode currentObject, String keyToSet, JsonNode valueNode) {
+ JsonNode existingData = currentObject.get(keyToSet);
+
+ if (existingData != null) {
+ // Don't overwrite existing non-empty value with new empty value.
+ if (valueNode == null || valueNode.isNull() || valueNode.isEmpty()) {
+ return;
+ }
+
+ // Don't fail when overwriting value with same value
+ if (valueNode.equals(existingData)) {
+ return;
+ }
+
+ // Instead of overwriting dictionary with another dictionary, merge them.
+ if (existingData.isObject() && valueNode.isObject()) {
+ ((ObjectNode) existingData).setAll((ObjectNode) valueNode);
+ } else {
+ throw new IllegalArgumentException(
+ "Cannot set value for an existing key. Key: "
+ + keyToSet
+ + "; Existing value: "
+ + existingData
+ + "; New value: "
+ + valueNode);
+ }
+ } else {
+ currentObject.set(keyToSet, valueNode);
}
}
@@ -221,6 +438,10 @@ public static void setValueByPath(ObjectNode jsonObject, String[] path, Object v
return currentObject;
}
+ /**
+ * Formats a template string by replacing {@code {key}} placeholders with values from {@code
+ * data}.
+ */
public static String formatMap(String template, JsonNode data) {
if (data == null) {
return template;
@@ -238,6 +459,10 @@ public static String formatMap(String template, JsonNode data) {
return template;
}
+ /**
+ * Checks whether the given object represents a zero or default empty value (e.g., null, 0, false,
+ * '\0').
+ */
public static boolean isZero(Object obj) {
if (obj == null) {
return true;
@@ -296,7 +521,7 @@ public static String urlEncode(ObjectNode paramsNode) {
/** Converts a snake_case string to camelCase. */
public static String snakeToCamel(String str) {
- if (str == null || str.isEmpty()) {
+ if (Strings.isNullOrEmpty(str)) {
return str;
}
@@ -333,7 +558,7 @@ public static void moveValueByPath(JsonNode data, Map paths) {
String[] destKeys = destPath.split("\\.");
// Determine keys to exclude from wildcard to avoid cyclic references
- java.util.Set excludeKeys = new java.util.HashSet<>();
+ Set excludeKeys = new HashSet<>();
int wildcardIdx = -1;
for (int i = 0; i < sourceKeys.length; i++) {
@@ -370,11 +595,7 @@ public static void moveValueByPath(JsonNode data, Map paths) {
* @param excludeKeys Keys to exclude when processing wildcards
*/
public static void moveValueRecursive(
- JsonNode data,
- String[] sourceKeys,
- String[] destKeys,
- int keyIdx,
- java.util.Set excludeKeys) {
+ JsonNode data, String[] sourceKeys, String[] destKeys, int keyIdx, Set excludeKeys) {
if (keyIdx >= sourceKeys.length || data == null) {
return;
}
@@ -398,7 +619,7 @@ public static void moveValueRecursive(
ObjectNode objectNode = (ObjectNode) data;
// Get all keys to move (excluding specified keys)
- java.util.List keysToMove = new java.util.ArrayList<>();
+ List keysToMove = new ArrayList<>();
Iterator fieldNames = objectNode.fieldNames();
while (fieldNames.hasNext()) {
String fieldName = fieldNames.next();
@@ -408,7 +629,7 @@ public static void moveValueRecursive(
}
// Collect values to move
- java.util.Map valuesToMove = new java.util.HashMap<>();
+ Map valuesToMove = new HashMap<>();
for (String k : keysToMove) {
valuesToMove.put(k, objectNode.get(k));
}
@@ -419,7 +640,7 @@ public static void moveValueRecursive(
JsonNode v = entry.getValue();
// Build destination keys with the field name
- java.util.List newDestKeysList = new java.util.ArrayList<>();
+ List newDestKeysList = new ArrayList<>();
for (int i = keyIdx; i < destKeys.length; i++) {
String dk = destKeys[i];
if (dk.equals("*")) {
diff --git a/src/main/java/com/google/genai/JsonSerializable.java b/src/main/java/com/google/genai/JsonSerializable.java
index 52d1db05f4e..55382db7759 100644
--- a/src/main/java/com/google/genai/JsonSerializable.java
+++ b/src/main/java/com/google/genai/JsonSerializable.java
@@ -16,173 +16,63 @@
package com.google.genai;
-import com.fasterxml.jackson.annotation.JsonInclude;
-import com.fasterxml.jackson.core.JsonGenerator;
-import com.fasterxml.jackson.core.JsonParser;
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.core.StreamReadConstraints;
-import com.fasterxml.jackson.databind.DeserializationContext;
-import com.fasterxml.jackson.databind.DeserializationFeature;
-import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.SerializationFeature;
-import com.fasterxml.jackson.databind.SerializerProvider;
-import com.fasterxml.jackson.databind.module.SimpleModule;
-import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
-import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.google.api.core.InternalApi;
-import com.google.genai.errors.GenAiIOException;
-import java.util.logging.Logger;
+import java.time.Duration;
/** A class that can be serialized to JSON and deserialized from JSON. */
public abstract class JsonSerializable {
- @InternalApi protected static final ObjectMapper objectMapper = new ObjectMapper();
- private static final Logger logger = Logger.getLogger(JsonSerializable.class.getName());
+ @InternalApi protected static final ObjectMapper objectMapper = Common.objectMapper;
/**
* System property to override the default max JSON string length (20MB) in read constraints.
* E.g., if you want to change the limit to 100MB, you can set it via
* `-Dgenai.json.maxReadLength=100000000`.
*/
- public static final String MAX_READ_LENGTH_PROPERTY = "genai.json.maxReadLength";
+ public static final String MAX_READ_LENGTH_PROPERTY = Common.MAX_READ_LENGTH_PROPERTY;
- /** Custom Jackson serializer for {@link java.time.Duration} to output "Xs" format. */
- static class CustomDurationSerializer extends JsonSerializer {
- @Override
- public void serialize(
- java.time.Duration duration,
- JsonGenerator jsonGenerator,
- SerializerProvider serializerProvider)
- throws java.io.IOException {
- if (duration == null) {
- jsonGenerator.writeNull();
- } else {
- jsonGenerator.writeString(duration.getSeconds() + "s");
- }
- }
+ /** Custom Jackson serializer for {@link Duration} to output "Xs" format. */
+ public static class CustomDurationSerializer extends Common.CustomDurationSerializer {
+ public CustomDurationSerializer() {}
}
- /** Custom Jackson deserializer for {@link java.time.Duration} to parse "Xs" format. */
- static class CustomDurationDeserializer extends JsonDeserializer {
- @Override
- public java.time.Duration deserialize(JsonParser p, DeserializationContext ctxt)
- throws java.io.IOException, JsonProcessingException {
- String value = p.getValueAsString();
-
- if (value == null || value.isEmpty()) {
- return null;
- }
- if (value.endsWith("s")) {
- String secondsPart = value.substring(0, value.length() - 1);
- try {
- long seconds = Long.parseLong(secondsPart);
- return java.time.Duration.ofSeconds(seconds);
- } catch (NumberFormatException e) {
- throw ctxt.weirdStringException(
- value,
- java.time.Duration.class,
- "Cannot parse duration from string: " + value + ". Expected format 'Xs'.");
- }
- } else {
- // If it doesn't end with 's', delegate to the default deserializer.
- throw ctxt.weirdStringException(
- value, java.time.Duration.class, "Expected duration in format 'Xs', but got: " + value);
- }
- }
- }
-
- /** Configures the stream read constraints for the JSON parser. */
- private static void configureStreamReadConstraints(int maxReadLength) {
- if (maxReadLength <= 0) {
- throw new IllegalArgumentException("Invalid JSON max read length: " + maxReadLength);
- }
- logger.info("Overriding default JSON max string length. New value = " + maxReadLength);
- StreamReadConstraints constraints =
- StreamReadConstraints.builder().maxStringLength(maxReadLength).build();
- objectMapper.getFactory().setStreamReadConstraints(constraints);
- }
-
- static {
- objectMapper.setSerializationInclusion(JsonInclude.Include.NON_ABSENT);
- objectMapper.registerModule(new Jdk8Module());
- // Disable writing dates as timestamps to use ISO-8601 string format for Instant
- objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
- objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
-
- // Create a module for custom serializers/deserializers
- SimpleModule customModule = new SimpleModule();
- customModule.addSerializer(java.time.Duration.class, new CustomDurationSerializer());
- customModule.addDeserializer(java.time.Duration.class, new CustomDurationDeserializer());
-
- // Register JavaTimeModule for other java.time types *before* the custom module
- // This ensures our custom Duration handling takes precedence over the default one
- // provided by JavaTimeModule.
- objectMapper.registerModule(new JavaTimeModule());
- objectMapper.registerModule(customModule);
-
- try {
- String propertyValue = System.getProperty(MAX_READ_LENGTH_PROPERTY);
- if (propertyValue != null && !propertyValue.isEmpty()) {
- int maxStringLength = Integer.parseInt(propertyValue);
- configureStreamReadConstraints(maxStringLength);
- }
- } catch (NumberFormatException e) {
- logger.warning(
- "Failed to parse system property ["
- + MAX_READ_LENGTH_PROPERTY
- + "]. Using default 20MB limit.");
- }
+ /** Custom Jackson deserializer for {@link Duration} to parse "Xs" format. */
+ public static class CustomDurationDeserializer extends Common.CustomDurationDeserializer {
+ public CustomDurationDeserializer() {}
}
/** Serializes the instance to a Json string. */
public String toJson() {
- return toJsonString(this);
+ return Common.toJsonString(this);
}
/** Serializes an object to a Json string. */
public static String toJsonString(Object object) {
- try {
- return objectMapper.writeValueAsString(object);
- } catch (JsonProcessingException e) {
- throw new GenAiIOException("Failed to serialize the object to JSON.", e);
- }
+ return Common.toJsonString(object);
}
/** Serializes an object to a JsonNode. */
public static JsonNode toJsonNode(Object object) {
- return objectMapper.valueToTree(object);
+ return Common.toJsonNode(object);
}
/** Deserializes a Json string to an object of the given type. This is for internal use only. */
@InternalApi
public static T fromJsonString(String jsonString, Class clazz) {
- try {
- return objectMapper.readValue(jsonString, clazz);
- } catch (JsonProcessingException e) {
- throw new GenAiIOException("Failed to deserialize the JSON string.", e);
- }
+ return Common.fromJsonString(jsonString, clazz);
}
/** Deserializes a JsonNode to an object of the given type. */
@InternalApi
public static T fromJsonNode(JsonNode jsonNode, Class clazz) {
- try {
- return objectMapper.treeToValue(jsonNode, clazz);
- } catch (JsonProcessingException e) {
- throw new GenAiIOException("Failed to deserialize the JSON node.", e);
- }
+ return Common.fromJsonNode(jsonNode, clazz);
}
/** Converts a Json string to a JsonNode. */
public static JsonNode stringToJsonNode(String string) {
- try {
- return objectMapper.readTree(string);
- } catch (JsonProcessingException e) {
- throw new GenAiIOException("Failed to parse the JSON string.", e);
- }
+ return Common.stringToJsonNode(string);
}
/**
@@ -194,10 +84,11 @@ public static JsonNode stringToJsonNode(String string) {
* @param maxReadLength the new maximum string length in bytes (e.g., 100_000_000 for 100MB).
*/
public static void setMaxReadLength(int maxReadLength) {
- configureStreamReadConstraints(maxReadLength);
+ Common.setMaxReadLength(maxReadLength);
}
+ /** Returns the shared {@link ObjectMapper} instance used by the SDK. */
public static ObjectMapper objectMapper() {
- return objectMapper;
+ return Common.objectMapper();
}
}