Experimental Function Calling tests for AnthropicApi low-level API
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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))));
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user