Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 132 additions & 37 deletions core/src/main/java/com/google/adk/models/Claude.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
Expand All @@ -55,6 +56,28 @@
public class Claude extends BaseLlm {

private static final Logger logger = LoggerFactory.getLogger(Claude.class);

// JSON Schema keywords traversed by updateTypeString, grouped by the shape of their value.
// Keywords whose value is a map of named sub-schemas (e.g. "properties": {"a": {...}}).
private static final ImmutableList<String> NESTED_SCHEMA_MAP_KEYWORDS =
ImmutableList.of("$defs", "defs", "dependentSchemas", "patternProperties", "properties");
// Keywords whose value is a single sub-schema (e.g. "items": {...}).
private static final ImmutableList<String> NESTED_SCHEMA_KEYWORDS =
ImmutableList.of(
"additionalProperties",
"additional_properties",
"contains",
"else",
"if",
"items",
"not",
"propertyNames",
"then",
"unevaluatedProperties");
// Keywords whose value is a list of sub-schemas (e.g. "anyOf": [{...}, {...}]).
private static final ImmutableList<String> NESTED_SCHEMA_LIST_KEYWORDS =
ImmutableList.of("allOf", "all_of", "anyOf", "any_of", "oneOf", "one_of", "prefixItems");

private int maxTokens = 8192;
private final AnthropicClient anthropicClient;

Expand Down Expand Up @@ -195,61 +218,133 @@ private String serializeToJson(Object obj) {
}
}

private void updateTypeString(Map<String, Object> valueDict) {
if (valueDict == null) {
/**
* Recursively lowercases JSON Schema {@code type} keywords for Anthropic compatibility.
*
* <p>{@code type} is only lowercased when it is a plain string. JSON Schema also permits a union
* type array (e.g. {@code ["string", "null"]}), which MCP tools can emit; those are traversed
* rather than cast to {@code String}. All nested sub-schemas (properties, {@code $defs}, {@code
* anyOf}, {@code items}, ...) are visited so deeply-nested types are normalized too.
*/
@SuppressWarnings("unchecked")
private void updateTypeString(Object value) {
if (value instanceof List) {
for (Object item : (List<Object>) value) {
updateTypeString(item);
}
return;
}
if (valueDict.containsKey("type")) {
valueDict.put("type", ((String) valueDict.get("type")).toLowerCase());
if (!(value instanceof Map)) {
return;
}
Map<String, Object> valueDict = (Map<String, Object>) value;

if (valueDict.containsKey("items")) {
updateTypeString((Map<String, Object>) valueDict.get("items"));

if (valueDict.get("items") instanceof Map
&& ((Map) valueDict.get("items")).containsKey("properties")) {
Map<String, Object> properties =
(Map<String, Object>) ((Map) valueDict.get("items")).get("properties");
if (properties != null) {
for (Object value : properties.values()) {
if (value instanceof Map) {
updateTypeString((Map<String, Object>) value);
}
}
Object schemaType = valueDict.get("type");
if (schemaType instanceof String) {
valueDict.put("type", ((String) schemaType).toLowerCase(Locale.ROOT));
}

for (String dictKey : NESTED_SCHEMA_MAP_KEYWORDS) {
Object child = valueDict.get(dictKey);
if (child instanceof Map) {
for (Object childValue : ((Map<String, Object>) child).values()) {
updateTypeString(childValue);
}
}
}

for (String singleKey : NESTED_SCHEMA_KEYWORDS) {
updateTypeString(valueDict.get(singleKey));
}

for (String listKey : NESTED_SCHEMA_LIST_KEYWORDS) {
updateTypeString(valueDict.get(listKey));
}
}

private Tool functionDeclarationToAnthropicTool(FunctionDeclaration functionDeclaration) {
Map<String, Map<String, Object>> properties = new HashMap<>();
if (functionDeclaration.parameters().isPresent()
&& functionDeclaration.parameters().get().properties().isPresent()) {
functionDeclaration
.parameters()
.get()
.properties()
.get()
.forEach(
(key, schema) -> {
Map<String, Object> schemaMap =
JsonBaseModel.getMapper()
.convertValue(schema, new TypeReference<Map<String, Object>>() {});
updateTypeString(schemaMap);
properties.put(key, schemaMap);
});
Map<String, Object> inputSchema;
if (functionDeclaration.parametersJsonSchema().isPresent()) {
// MCP tools populate parametersJsonSchema (a raw JSON Schema object) instead of the
// structured parameters() field. Pass the whole schema through -- as a mutable copy -- so
// keys such as $ref/$defs/additionalProperties are preserved rather than dropped, then
// lowercase any type strings for Anthropic compatibility.
inputSchema =
JsonBaseModel.getMapper()
.convertValue(
functionDeclaration.parametersJsonSchema().get(),
new TypeReference<Map<String, Object>>() {});
} else {
Map<String, Object> properties = new HashMap<>();
List<String> required = new ArrayList<>();
if (functionDeclaration.parameters().isPresent()
&& functionDeclaration.parameters().get().properties().isPresent()) {
functionDeclaration
.parameters()
.get()
.properties()
.get()
.forEach(
(key, schema) ->
properties.put(
key,
JsonBaseModel.getMapper()
.convertValue(schema, new TypeReference<Map<String, Object>>() {})));
functionDeclaration.parameters().get().required().ifPresent(required::addAll);
}
inputSchema = new HashMap<>();
inputSchema.put("type", "object");
inputSchema.put("properties", properties);
if (!required.isEmpty()) {
inputSchema.put("required", required);
}
}
updateTypeString(inputSchema);

return Tool.builder()
.name(functionDeclaration.name().orElseThrow())
.description(functionDeclaration.description().orElse(""))
.inputSchema(
Tool.InputSchema.builder()
.properties(com.anthropic.core.JsonValue.from(properties))
.build())
.inputSchema(toAnthropicInputSchema(inputSchema))
.build();
}

/**
* Builds an Anthropic {@link Tool.InputSchema} from a JSON Schema map, preserving every top-level
* keyword (e.g. {@code $defs}, {@code additionalProperties}) as an additional property so schemas
* that rely on {@code $ref}/{@code $defs} are not sent to Claude with dangling references.
*/
private Tool.InputSchema toAnthropicInputSchema(Map<String, Object> schema) {
Tool.InputSchema.Builder builder = Tool.InputSchema.builder();
schema.forEach(
(key, value) -> {
switch (key) {
case "type":
// The Anthropic input schema type is always "object"; skip to avoid a duplicate key.
break;
case "properties":
builder.properties(
com.anthropic.core.JsonValue.from(value == null ? new HashMap<>() : value));
break;
case "required":
if (value instanceof List) {
List<String> required = new ArrayList<>();
for (Object item : (List<?>) value) {
if (item != null) {
required.add(item.toString());
}
}
builder.required(required);
} else {
builder.putAdditionalProperty(key, com.anthropic.core.JsonValue.from(value));
}
break;
default:
builder.putAdditionalProperty(key, com.anthropic.core.JsonValue.from(value));
}
});
return builder.build();
}

private LlmResponse convertAnthropicResponseToLlmResponse(Message message) {
LlmResponse.Builder responseBuilder = LlmResponse.builder();
List<Part> parts = new ArrayList<>();
Expand Down
153 changes: 153 additions & 0 deletions core/src/test/java/com/google/adk/models/ClaudeTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,19 @@
import static org.mockito.Mockito.when;

import com.anthropic.client.AnthropicClient;
import com.anthropic.core.JsonValue;
import com.anthropic.models.messages.ContentBlockParam;
import com.anthropic.models.messages.Message;
import com.anthropic.models.messages.Tool;
import com.anthropic.models.messages.ToolResultBlockParam;
import com.anthropic.models.messages.Usage;
import com.fasterxml.jackson.core.type.TypeReference;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.genai.types.FunctionDeclaration;
import com.google.genai.types.FunctionResponse;
import com.google.genai.types.Part;
import com.google.genai.types.Schema;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.Map;
Expand All @@ -44,6 +50,7 @@ public final class ClaudeTest {

private Claude claude;
private Method partToAnthropicMessageBlockMethod;
private Method functionDeclarationToAnthropicToolMethod;

@Before
public void setUp() throws Exception {
Expand All @@ -54,6 +61,17 @@ public void setUp() throws Exception {
partToAnthropicMessageBlockMethod =
Claude.class.getDeclaredMethod("partToAnthropicMessageBlock", Part.class);
partToAnthropicMessageBlockMethod.setAccessible(true);

functionDeclarationToAnthropicToolMethod =
Claude.class.getDeclaredMethod(
"functionDeclarationToAnthropicTool", FunctionDeclaration.class);
functionDeclarationToAnthropicToolMethod.setAccessible(true);
}

@SuppressWarnings("unchecked")
private static Map<String, Object> inputSchemaProperties(Tool tool) {
JsonValue properties = (JsonValue) tool.inputSchema()._properties();
return properties.convert(new TypeReference<Map<String, Object>>() {});
}

@Test
Expand Down Expand Up @@ -107,4 +125,139 @@ public void testClaudeUsageMapping_ShouldFailWhenMappingIsMissing() throws Excep
assertEquals(
outputTokens, (long) result.usageMetadata().get().candidatesTokenCount().orElse(0));
}

@Test
public void functionDeclarationToAnthropicTool_usesParameters() throws Exception {
FunctionDeclaration functionDeclaration =
FunctionDeclaration.builder()
.name("retrievesItemByItemNumber")
.description("Retrieves an item")
.parameters(
Schema.builder()
.type("OBJECT")
.properties(
ImmutableMap.of("itemNumber", Schema.builder().type("STRING").build()))
.required(ImmutableList.of("itemNumber"))
.build())
.build();

Tool tool = (Tool) functionDeclarationToAnthropicToolMethod.invoke(claude, functionDeclaration);

Map<String, Object> properties = inputSchemaProperties(tool);
assertThat(properties).containsKey("itemNumber");
// The genai type "STRING" is lowercased to the JSON Schema "string" for Claude.
assertThat(((Map<String, Object>) properties.get("itemNumber")).get("type"))
.isEqualTo("string");
assertThat(tool.inputSchema().required()).hasValue(ImmutableList.of("itemNumber"));
}

@Test
public void functionDeclarationToAnthropicTool_fallsBackToParametersJsonSchema()
throws Exception {
// MCP tools populate parametersJsonSchema instead of the structured parameters() field.
Map<String, Object> jsonSchema =
ImmutableMap.of(
"type",
"object",
"properties",
ImmutableMap.of(
"dataset",
ImmutableMap.of("type", "string", "description", "The dataset id"),
"project",
ImmutableMap.of("type", "string")),
"required",
ImmutableList.of("dataset"));
FunctionDeclaration functionDeclaration =
FunctionDeclaration.builder()
.name("get_dataset_info")
.description("Gets dataset info")
.parametersJsonSchema(jsonSchema)
.build();

Tool tool = (Tool) functionDeclarationToAnthropicToolMethod.invoke(claude, functionDeclaration);

Map<String, Object> properties = inputSchemaProperties(tool);
// Before the fix these properties were empty, so Claude could not invoke the MCP tool.
assertThat(properties).containsKey("dataset");
assertThat(properties).containsKey("project");
assertThat(((Map<String, Object>) properties.get("dataset")).get("type")).isEqualTo("string");
assertThat(tool.inputSchema().required()).hasValue(ImmutableList.of("dataset"));
}

@Test
public void functionDeclarationToAnthropicTool_noParameters_hasEmptyProperties()
throws Exception {
FunctionDeclaration functionDeclaration =
FunctionDeclaration.builder().name("no_args_tool").description("Takes no args").build();

Tool tool = (Tool) functionDeclarationToAnthropicToolMethod.invoke(claude, functionDeclaration);

Map<String, Object> properties = inputSchemaProperties(tool);
assertThat(properties).isEmpty();
assertThat(tool.inputSchema().required()).isEmpty();
}

@Test
public void functionDeclarationToAnthropicTool_unionTypeArray_doesNotThrow() throws Exception {
// JSON Schema permits a union type array (e.g. ["string", "null"]), which MCP tools can emit.
// It must not be cast to String, which previously threw ClassCastException.
Map<String, Object> jsonSchema =
ImmutableMap.of(
"type",
"object",
"properties",
ImmutableMap.of(
"nickname", ImmutableMap.of("type", ImmutableList.of("string", "null"))));
FunctionDeclaration functionDeclaration =
FunctionDeclaration.builder()
.name("set_nickname")
.description("Sets a nickname")
.parametersJsonSchema(jsonSchema)
.build();

Tool tool = (Tool) functionDeclarationToAnthropicToolMethod.invoke(claude, functionDeclaration);

Map<String, Object> properties = inputSchemaProperties(tool);
assertThat(properties).containsKey("nickname");
// The union type array is preserved rather than crashing on the String cast.
assertThat(((Map<String, Object>) properties.get("nickname")).get("type"))
.isEqualTo(ImmutableList.of("string", "null"));
}

@Test
public void functionDeclarationToAnthropicTool_preservesRefsAndDefs() throws Exception {
// MCP tools may use $ref/$defs. These top-level keywords must survive so Claude does not
// receive dangling references.
Map<String, Object> jsonSchema =
ImmutableMap.of(
"type",
"object",
"properties",
ImmutableMap.of("pet", ImmutableMap.of("$ref", "#/$defs/Pet")),
"$defs",
ImmutableMap.of(
"Pet",
ImmutableMap.of(
"type",
"object",
"properties",
ImmutableMap.of("name", ImmutableMap.of("type", "string")))));
FunctionDeclaration functionDeclaration =
FunctionDeclaration.builder()
.name("register_pet")
.description("Registers a pet")
.parametersJsonSchema(jsonSchema)
.build();

Tool tool = (Tool) functionDeclarationToAnthropicToolMethod.invoke(claude, functionDeclaration);

// The $ref is kept on the property...
Map<String, Object> properties = inputSchemaProperties(tool);
assertThat(((Map<String, Object>) properties.get("pet")).get("$ref")).isEqualTo("#/$defs/Pet");
// ...and the $defs block survives as a top-level keyword.
JsonValue defsValue = (JsonValue) tool.inputSchema()._additionalProperties().get("$defs");
assertThat(defsValue).isNotNull();
Map<String, Object> defs = defsValue.convert(new TypeReference<Map<String, Object>>() {});
assertThat(defs).containsKey("Pet");
}
}
Loading