Experimental Function Calling tests for AnthropicApi low-level API

This commit is contained in:
Christian Tzolov
2024-03-22 14:34:37 +01:00
parent 50c464fd16
commit 09fb5743d2
6 changed files with 445 additions and 7 deletions

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.ai</groupId>
@@ -74,6 +75,14 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
<!-- <version>2.16.1</version> -->
<version>2.11.1</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -34,6 +34,7 @@ import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionRequest;
import org.springframework.ai.anthropic.api.AnthropicApi.Role;
import org.springframework.ai.anthropic.api.AnthropicApi.StreamResponse;
import org.springframework.ai.anthropic.api.AnthropicApi.Usage;
import org.springframework.ai.anthropic.api.AnthropicApi.MediaContent.Type;
import org.springframework.ai.anthropic.metadata.AnthropicChatResponseMetadata;
import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.chat.ChatResponse;
@@ -160,7 +161,8 @@ public class AnthropicChatClient implements ChatClient, StreamingChatClient {
chatCompletionReference.get().withType(chunk.type()).withContent(List.of(content));
}
else if (chunk.type().equals("content_block_delta")) {
var content = new MediaContent("text_delta", null, (String) chunk.delta().get("text"), chunk.index());
var content = new MediaContent(Type.TEXT_DELTA, null, (String) chunk.delta().get("text"),
chunk.index());
chatCompletionReference.get().withType(chunk.type()).withContent(List.of(content));
}
else if (chunk.type().equals("message_delta")) {

View File

@@ -42,6 +42,10 @@ import org.springframework.web.reactive.function.client.WebClient;
*/
public class AnthropicApi {
private static final String HEADER_X_API_KEY = "x-api-key";
private static final String HEADER_ANTHROPIC_VERSION = "anthropic-version";
public static final String DEFAULT_BASE_URL = "https://api.anthropic.com";
public static final String DEFAULT_ANTHROPIC_VERSION = "2023-06-01";
@@ -81,8 +85,8 @@ public class AnthropicApi {
RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) {
Consumer<HttpHeaders> jsonContentHeaders = headers -> {
headers.add("x-api-key", anthropicApiKey);
headers.add("anthropic-version", anthropicVersion);
headers.add(HEADER_X_API_KEY, anthropicApiKey);
headers.add(HEADER_ANTHROPIC_VERSION, anthropicVersion);
headers.setContentType(MediaType.APPLICATION_JSON);
};
@@ -181,6 +185,11 @@ public class AnthropicApi {
this(model, messages, system, maxTokens, null, null, stream, temperature, null, null);
}
public ChatCompletionRequest(String model, List<RequestMessage> messages, String system, Integer maxTokens,
List<String> stopSequences, Float temperature, Boolean stream) {
this(model, messages, system, maxTokens, null, stopSequences, stream, temperature, null, null);
}
/**
* @param userId An external identifier for the user who is associated with the
* request. This should be a uuid, hash value, or other opaque identifier.
@@ -226,7 +235,7 @@ public class AnthropicApi {
*/
@JsonInclude(Include.NON_NULL)
public record MediaContent( // @formatter:off
@JsonProperty("type") String type,
@JsonProperty("type") Type type,
@JsonProperty("source") Source source,
@JsonProperty("text") String text,
@JsonProperty("index") Integer index // applicable only for streaming responses.
@@ -238,11 +247,36 @@ public class AnthropicApi {
}
public MediaContent(Source source) {
this("image", source, null, null);
this(Type.IMAGE, source, null, null);
}
public MediaContent(String text) {
this("text", null, text, null);
this(Type.TEXT, null, text, null);
}
/**
* The type of this message.
*/
public enum Type {
/**
* Text message.
*/
@JsonProperty("text")
TEXT,
/**
* Text delta message. Returned from the streaming response.
*/
@JsonProperty("text_delta")
TEXT_DELTA,
/**
* Image message.
*/
@JsonProperty("image")
IMAGE;
}
/**

View File

@@ -0,0 +1,152 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.anthropic.api.tool;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletion;
import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionRequest;
import org.springframework.ai.anthropic.api.AnthropicApi.MediaContent;
import org.springframework.ai.anthropic.api.AnthropicApi.RequestMessage;
import org.springframework.ai.anthropic.api.AnthropicApi.Role;
import org.springframework.ai.anthropic.api.tool.XmlHelper.FunctionCalls;
import org.springframework.ai.anthropic.api.tool.XmlHelper.Tools;
import org.springframework.ai.anthropic.api.tool.XmlHelper.Tools.ToolDescription;
import org.springframework.ai.anthropic.api.tool.XmlHelper.Tools.ToolDescription.Parameter;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Experiments with
* <a href="https://docs.anthropic.com/claude/docs/functions-external-tools">Anthropic
* Functions & external tools</a>.
*
* <p>
* <a href=
* "https://www.linkedin.com/pulse/tool-usefunction-calling-anthropics-claude-3-opus-llm-micky-multani-fsmrc">Tool
* Use(Function Calling) with Anthropic's Claude 3 Opus LLM</a>
* <p>
* <a href=
* "https://www.codeproject.com/Articles/5379174/Csharp-Anthropic-Claude-Library-You-Can-Call-Claud">Anthropic
* Functions & external tools</a>
*
* @author Christian Tzolov
*/
@EnabledIfEnvironmentVariable(named = "ANTHROPIC_API_KEY", matches = ".+")
@SuppressWarnings("null")
public class AnthropicApiToolIT {
private static final Logger logger = LoggerFactory.getLogger(AnthropicApiToolIT.class);
AnthropicApi anthropicApi = new AnthropicApi(System.getenv("ANTHROPIC_API_KEY"));
public static final String TOO_SYSTEM_PROMPT_TEMPLATE = """
In this environment you have access to a set of tools you can use to answer the user's question.
You may call them like this:
<function_calls>
<invoke>
<tool_name>$TOOL_NAME</tool_name>
<parameters>
<$PARAMETER_NAME>$PARAMETER_VALUE</$PARAMETER_NAME>
...
</parameters>
</invoke>
</function_calls>
Here are the tools available:
<tools>%s</tools>
""";
public static final ConcurrentHashMap<String, Function> FUNCTIONS = new ConcurrentHashMap<>();
static {
FUNCTIONS.put("getCurrentWeather", new MockWeatherService());
}
@Test
void toolCalls() {
String toolDescription = XmlHelper.toXml(new Tools(List.of(new ToolDescription("getCurrentWeather",
"Get the weather in location. Return temperature in 30°F or 30°C format.",
List.of(new Parameter("location", "string", "The city and state e.g. San Francisco, CA"),
new Parameter("unit", "enum", "Temperature unit. Use only C or F. Default is C."))))));
logger.info("TOOLS: " + toolDescription);
String systemPrompt = String.format(TOO_SYSTEM_PROMPT_TEMPLATE, toolDescription);
RequestMessage chatCompletionMessage = new RequestMessage(
List.of(new MediaContent("What's the weather like in Paris? Show the temperature in Celsius.")),
// "What's the weather like in San Francisco, Tokyo, and Paris? Show the
// temperature in Celsius.")),
Role.USER);
ChatCompletionRequest chatCompletionRequest = new ChatCompletionRequest(
AnthropicApi.ChatModel.CLAUDE_3_OPUS.getValue(), List.of(chatCompletionMessage), systemPrompt, 500,
0.8f, false);
ResponseEntity<ChatCompletion> chatCompletion = doCall(chatCompletionRequest);
var responseText = chatCompletion.getBody().content().get(0).text();
logger.info("FINAL RESPONSE: " + responseText);
assertThat(responseText).contains("15");
}
private ResponseEntity<ChatCompletion> doCall(ChatCompletionRequest chatCompletionRequest) {
ResponseEntity<ChatCompletion> response = anthropicApi.chatCompletionEntity(chatCompletionRequest);
FunctionCalls functionCalls = XmlHelper.extractFunctionCalls(response.getBody().content().get(0).text());
if (functionCalls == null) {
return response;
}
logger.info("FunctionCalls from the LLM: " + functionCalls);
MockWeatherService.Request request = ModelOptionsUtils.mapToClass(functionCalls.invoke().parameters(),
MockWeatherService.Request.class);
logger.info("Resolved function request param: " + request);
Object functionCallResponseData = FUNCTIONS.get(functionCalls.invoke().toolName()).apply(request);
XmlHelper.FunctionResults functionResults = new XmlHelper.FunctionResults(List
.of(new XmlHelper.FunctionResults.Result(functionCalls.invoke().toolName(), functionCallResponseData)));
String content = XmlHelper.toXml(functionResults);
logger.info("Function response XML : " + content);
RequestMessage chatCompletionMessage2 = new RequestMessage(List.of(new MediaContent(content)), Role.USER);
return doCall(new ChatCompletionRequest(AnthropicApi.ChatModel.CLAUDE_3_OPUS.getValue(),
List.of(chatCompletionMessage2), null, 500, 0.8f, false));
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.anthropic.api.tool;
import java.util.function.Function;
import com.fasterxml.jackson.annotation.JsonClassDescription;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
/**
* @author Christian Tzolov
*/
public class MockWeatherService implements Function<MockWeatherService.Request, MockWeatherService.Response> {
/**
* Weather Function request.
*/
@JsonInclude(Include.NON_NULL)
@JsonClassDescription("Weather API request")
public record Request(@JsonProperty(required = true,
value = "location") @JsonPropertyDescription("The city and state e.g. San Francisco, CA") String location,
@JsonProperty(required = true, value = "unit") @JsonPropertyDescription("Temperature unit") Unit unit) {
}
/**
* Temperature units.
*/
public enum Unit {
/**
* Celsius.
*/
C("metric"),
/**
* Fahrenheit.
*/
F("imperial");
/**
* Human readable unit name.
*/
public final String unitName;
private Unit(String text) {
this.unitName = text;
}
}
/**
* Weather Function response.
*/
public record Response(double temp, Unit unit) {
}
@Override
public Response apply(Request request) {
double temperature = 0;
if (request.location().contains("Paris")) {
temperature = 15;
}
else if (request.location().contains("Tokyo")) {
temperature = 10;
}
else if (request.location().contains("San Francisco")) {
temperature = 30;
}
return new Response(temperature, Unit.C);
}
}

View File

@@ -0,0 +1,152 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.anthropic.api.tool;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
import org.springframework.util.StringUtils;
/**
* @author Christian Tzolov
*/
public class XmlHelper {
// Regular expression to match XML block between <function_calls> and
// </function_calls> tags
private static final String FUNCTION_CALLS_REGEX = "<function_calls>.*?</function_calls>";
// Compile the regular expression pattern
private static final Pattern FUNCTION_CALLS_PATTERN = Pattern.compile(FUNCTION_CALLS_REGEX, Pattern.DOTALL);
private static final XmlMapper xmlMapper = new XmlMapper();
@JsonInclude(Include.NON_NULL) // @formatter:off
@JacksonXmlRootElement(localName = "tools")
public record Tools(
@JacksonXmlElementWrapper(useWrapping = false) @JsonProperty("tool_description") List<ToolDescription> toolDescriptions) {
public record ToolDescription(
@JsonProperty("tool_name") String toolName,
@JsonProperty("description") String description,
@JacksonXmlElementWrapper(localName = "parameters") @JsonProperty("parameter") List<Parameter> parameters) {
@JacksonXmlRootElement(localName = "parameter")
public record Parameter(
@JsonProperty("name") String name,
@JsonProperty("type") String type,
@JsonProperty("description") String description) {
}
}
} // @formatter:on
@JsonInclude(Include.NON_NULL) // @formatter:off
@JacksonXmlRootElement(localName = "function_calls")
public record FunctionCalls(@JsonProperty("invoke") Invoke invoke) {
public record Invoke(
@JsonProperty("tool_name") String toolName,
@JsonProperty("parameters") Map<String, Object> parameters) {
}
} // @formatter:on
@JsonInclude(Include.NON_NULL) // @formatter:off
@JacksonXmlRootElement(localName = "function_results")
public record FunctionResults(
@JacksonXmlElementWrapper(useWrapping = false) @JsonProperty("result") List<Result> result) {
public record Result(
@JsonProperty("tool_name") String toolName,
@JsonProperty("stdout") Object stdout) {
}
} // @formatter:on
public static String extractFunctionCallsXmlBlock(String text) {
if (!StringUtils.hasText(text)) {
return "";
}
Matcher matcher = FUNCTION_CALLS_PATTERN.matcher(text);
// Find and print the XML block
return (matcher.find()) ? matcher.group() : "";
}
public static FunctionCalls extractFunctionCalls(String text) {
String xml = extractFunctionCallsXmlBlock(text);
if (!StringUtils.hasText(xml)) {
return null;
}
try {
FunctionCalls functionCalls = xmlMapper.readValue(xml, FunctionCalls.class);
return functionCalls;
}
catch (Exception e) {
e.printStackTrace();
return null;
}
}
public static String toXml(Object object) {
try {
return xmlMapper.writerWithDefaultPrettyPrinter().writeValueAsString(object);
}
catch (JsonProcessingException e) {
e.printStackTrace();
return "";
}
}
public static void main(String[] args) throws JsonMappingException, JsonProcessingException {
String sample = """
<function_calls>
<invoke>
<tool_name>getCurrentWeather</tool_name>
<parameters>
<location>San Francisco, CA</location>
<unit>Celsius</unit>
</parameters>
</invoke>
</function_calls>
""";
System.out.println(extractFunctionCalls(sample));
var toolDescription = new Tools.ToolDescription("getCurrentWeather",
"Get the weather in location. Return temperature in 30°F or 30°C format.",
List.of(new Tools.ToolDescription.Parameter("location", "string",
"The city and state e.g. San Francisco, CA"),
new Tools.ToolDescription.Parameter("unit", "enum", "Temperature unit")));
System.out.println(toXml(new Tools(List.of(toolDescription))));
}
}