Add core deepseek classes

Signed-off-by: Mark Pollack <mark.pollack@broadcom.com>
This commit is contained in:
Mark Pollack
2025-05-07 21:27:39 -04:00
parent d781199b48
commit a5920a17fa
24 changed files with 3738 additions and 0 deletions

View File

@@ -0,0 +1 @@
[DeepSeek Chat Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/chat/deepseek-chat.html)

View File

@@ -0,0 +1,64 @@
<?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">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>spring-ai-deepseek</artifactId>
<packaging>jar</packaging>
<name>Spring AI DeepSeek</name>
<description>DeepSeek support</description>
<url>https://github.com/spring-projects/spring-ai</url>
<scm>
<url>https://github.com/spring-projects/spring-ai</url>
<connection>git://github.com/spring-projects/spring-ai.git</connection>
<developerConnection>git@github.com:spring-projects/spring-ai.git</developerConnection>
</scm>
<dependencies>
<!-- production dependencies -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-client-chat</artifactId>
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-retry</artifactId>
<version>${project.parent.version}</version>
</dependency>
<!-- Spring Framework -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<!-- test dependencies -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-test</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-observation-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,94 @@
package org.springframework.ai.deepseek;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.content.Media;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public class DeepSeekAssistantMessage extends AssistantMessage {
private Boolean prefix;
private String reasoningContent;
public DeepSeekAssistantMessage(String content) {
super(content);
}
public DeepSeekAssistantMessage(String content, String reasoningContent) {
super(content);
this.reasoningContent = reasoningContent;
}
public DeepSeekAssistantMessage(String content, Map<String, Object> properties) {
super(content, properties);
}
public DeepSeekAssistantMessage(String content, Map<String, Object> properties, List<ToolCall> toolCalls) {
super(content, properties, toolCalls);
}
public DeepSeekAssistantMessage(String content, String reasoningContent, Map<String, Object> properties,
List<ToolCall> toolCalls) {
this(content, reasoningContent, properties, toolCalls, List.of());
}
public DeepSeekAssistantMessage(String content, String reasoningContent, Map<String, Object> properties,
List<ToolCall> toolCalls, List<Media> media) {
super(content, properties, toolCalls, media);
this.reasoningContent = reasoningContent;
}
public static DeepSeekAssistantMessage prefixAssistantMessage(String context) {
return prefixAssistantMessage(context, null);
}
public static DeepSeekAssistantMessage prefixAssistantMessage(String context, String reasoningContent) {
return new DeepSeekAssistantMessage(context, reasoningContent);
}
public Boolean getPrefix() {
return prefix;
}
public void setPrefix(Boolean prefix) {
this.prefix = prefix;
}
public String getReasoningContent() {
return reasoningContent;
}
public void setReasoningContent(String reasoningContent) {
this.reasoningContent = reasoningContent;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof DeepSeekAssistantMessage that)) {
return false;
}
if (!super.equals(o)) {
return false;
}
return Objects.equals(this.reasoningContent, that.reasoningContent) && Objects.equals(this.prefix, that.prefix);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), this.prefix, this.reasoningContent);
}
@Override
public String toString() {
return "AssistantMessage [messageType=" + this.messageType + ", toolCalls=" + super.getToolCalls()
+ ", textContent=" + this.textContent + ", reasoningContent=" + this.reasoningContent + ", prefix="
+ this.prefix + ", metadata=" + this.metadata + "]";
}
}

View File

@@ -0,0 +1,566 @@
/*
* 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.deepseek;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.metadata.*;
import org.springframework.ai.chat.model.*;
import org.springframework.ai.chat.observation.ChatModelObservationContext;
import org.springframework.ai.chat.observation.ChatModelObservationConvention;
import org.springframework.ai.chat.observation.ChatModelObservationDocumentation;
import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.deepseek.api.DeepSeekApi;
import org.springframework.ai.deepseek.api.DeepSeekApi.ChatCompletion;
import org.springframework.ai.deepseek.api.DeepSeekApi.ChatCompletion.Choice;
import org.springframework.ai.deepseek.api.DeepSeekApi.ChatCompletionMessage;
import org.springframework.ai.deepseek.api.DeepSeekApi.ChatCompletionMessage.ChatCompletionFunction;
import org.springframework.ai.deepseek.api.DeepSeekApi.ChatCompletionMessage.ToolCall;
import org.springframework.ai.deepseek.api.DeepSeekApi.ChatCompletionRequest;
import org.springframework.ai.deepseek.api.common.DeepSeekConstants;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.model.tool.*;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.http.ResponseEntity;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* {@link ChatModel} and {@link StreamingChatModel} implementation for {@literal DeepSeek}
* backed by {@link DeepSeekApi}.
*
* @author Geng Rong
*/
public class DeepSeekChatModel implements ChatModel {
private static final Logger logger = LoggerFactory.getLogger(DeepSeekChatModel.class);
private static final ChatModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultChatModelObservationConvention();
private static final ToolCallingManager DEFAULT_TOOL_CALLING_MANAGER = ToolCallingManager.builder().build();
/**
* The default options used for the chat completion requests.
*/
private final DeepSeekChatOptions defaultOptions;
/**
* The retry template used to retry the DeepSeek API calls.
*/
public final RetryTemplate retryTemplate;
/**
* Low-level access to the DeepSeek API.
*/
private final DeepSeekApi deepSeekApi;
/**
* Observation registry used for instrumentation.
*/
private final ObservationRegistry observationRegistry;
/**
* The tool calling manager used to execute tools.
*/
private final ToolCallingManager toolCallingManager;
/**
* The tool execution eligibility predicate used to determine if a tool can be
* executed.
*/
private final ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate;
/**
* Conventions to use for generating observations.
*/
private ChatModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION;
public DeepSeekChatModel(DeepSeekApi deepSeekApi, DeepSeekChatOptions defaultOptions,
ToolCallingManager toolCallingManager, RetryTemplate retryTemplate,
ObservationRegistry observationRegistry) {
this(deepSeekApi, defaultOptions, toolCallingManager, retryTemplate, observationRegistry,
new DefaultToolExecutionEligibilityPredicate());
}
public DeepSeekChatModel(DeepSeekApi deepSeekApi, DeepSeekChatOptions defaultOptions,
ToolCallingManager toolCallingManager, RetryTemplate retryTemplate, ObservationRegistry observationRegistry,
ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate) {
Assert.notNull(deepSeekApi, "deepSeekApi cannot be null");
Assert.notNull(defaultOptions, "defaultOptions cannot be null");
Assert.notNull(toolCallingManager, "toolCallingManager cannot be null");
Assert.notNull(retryTemplate, "retryTemplate cannot be null");
Assert.notNull(observationRegistry, "observationRegistry cannot be null");
Assert.notNull(toolExecutionEligibilityPredicate, "toolExecutionEligibilityPredicate cannot be null");
this.deepSeekApi = deepSeekApi;
this.defaultOptions = defaultOptions;
this.toolCallingManager = toolCallingManager;
this.retryTemplate = retryTemplate;
this.observationRegistry = observationRegistry;
this.toolExecutionEligibilityPredicate = toolExecutionEligibilityPredicate;
}
@Override
public ChatResponse call(Prompt prompt) {
Prompt requestPrompt = buildRequestPrompt(prompt);
return this.internalCall(requestPrompt, null);
}
public ChatResponse internalCall(Prompt prompt, ChatResponse previousChatResponse) {
ChatCompletionRequest request = createRequest(prompt, false);
ChatModelObservationContext observationContext = ChatModelObservationContext.builder()
.prompt(prompt)
.provider(DeepSeekConstants.PROVIDER_NAME)
.build();
ChatResponse response = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION
.observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext,
this.observationRegistry)
.observe(() -> {
ResponseEntity<ChatCompletion> completionEntity = this.retryTemplate
.execute(ctx -> this.deepSeekApi.chatCompletionEntity(request));
var chatCompletion = completionEntity.getBody();
if (chatCompletion == null) {
logger.warn("No chat completion returned for prompt: {}", prompt);
return new ChatResponse(List.of());
}
List<Choice> choices = chatCompletion.choices();
if (choices == null) {
logger.warn("No choices returned for prompt: {}", prompt);
return new ChatResponse(List.of());
}
List<Generation> generations = choices.stream().map(choice -> {
// @formatter:off
Map<String, Object> metadata = Map.of(
"id", chatCompletion.id() != null ? chatCompletion.id() : "",
"role", choice.message().role() != null ? choice.message().role().name() : "",
"index", choice.index(),
"finishReason", choice.finishReason() != null ? choice.finishReason().name() : "");
// @formatter:on
return buildGeneration(choice, metadata);
}).toList();
// Current usage
DeepSeekApi.Usage usage = completionEntity.getBody().usage();
Usage currentChatResponseUsage = usage != null ? getDefaultUsage(usage) : new EmptyUsage();
Usage accumulatedUsage = UsageUtils.getCumulativeUsage(currentChatResponseUsage, previousChatResponse);
ChatResponse chatResponse = new ChatResponse(generations,
from(completionEntity.getBody(), accumulatedUsage));
observationContext.setResponse(chatResponse);
return chatResponse;
});
if (this.toolExecutionEligibilityPredicate.isToolExecutionRequired(prompt.getOptions(), response)) {
var toolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, response);
if (toolExecutionResult.returnDirect()) {
// Return tool execution result directly to the client.
return ChatResponse.builder()
.from(response)
.generations(ToolExecutionResult.buildGenerations(toolExecutionResult))
.build();
}
else {
// Send the tool execution result back to the model.
return this.internalCall(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()),
response);
}
}
return response;
}
@Override
public Flux<ChatResponse> stream(Prompt prompt) {
Prompt requestPrompt = buildRequestPrompt(prompt);
return internalStream(requestPrompt, null);
}
public Flux<ChatResponse> internalStream(Prompt prompt, ChatResponse previousChatResponse) {
return Flux.deferContextual(contextView -> {
ChatCompletionRequest request = createRequest(prompt, true);
Flux<DeepSeekApi.ChatCompletionChunk> completionChunks = this.deepSeekApi.chatCompletionStream(request);
// For chunked responses, only the first chunk contains the choice role.
// The rest of the chunks with same ID share the same role.
ConcurrentHashMap<String, String> roleMap = new ConcurrentHashMap<>();
final ChatModelObservationContext observationContext = ChatModelObservationContext.builder()
.prompt(prompt)
.provider(DeepSeekConstants.PROVIDER_NAME)
.build();
Observation observation = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION.observation(
this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext,
this.observationRegistry);
observation.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null)).start();
Flux<ChatResponse> chatResponse = completionChunks.map(this::chunkToChatCompletion)
.switchMap(chatCompletion -> Mono.just(chatCompletion).map(chatCompletion2 -> {
try {
String id = chatCompletion2.id();
List<Generation> generations = chatCompletion2.choices().stream().map(choice -> {
if (choice.message().role() != null) {
roleMap.putIfAbsent(id, choice.message().role().name());
}
// @formatter:off
Map<String, Object> metadata = Map.of(
"id", chatCompletion2.id(),
"role", roleMap.getOrDefault(id, ""),
"finishReason", choice.finishReason() != null ? choice.finishReason().name() : ""
);
// @formatter:on
return buildGeneration(choice, metadata);
}).toList();
DeepSeekApi.Usage usage = chatCompletion2.usage();
Usage currentUsage = (usage != null) ? getDefaultUsage(usage) : new EmptyUsage();
Usage cumulativeUsage = UsageUtils.getCumulativeUsage(currentUsage, previousChatResponse);
return new ChatResponse(generations, from(chatCompletion2, cumulativeUsage));
}
catch (Exception e) {
logger.error("Error processing chat completion", e);
return new ChatResponse(List.of());
}
}));
// @formatter:off
Flux<ChatResponse> flux = chatResponse.flatMap(response -> {
if (this.toolExecutionEligibilityPredicate.isToolExecutionRequired(prompt.getOptions(), response)) {
return Flux.defer(() -> {
// FIXME: bounded elastic needs to be used since tool calling
// is currently only synchronous
var toolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, response);
if (toolExecutionResult.returnDirect()) {
// Return tool execution result directly to the client.
return Flux.just(ChatResponse.builder().from(response)
.generations(ToolExecutionResult.buildGenerations(toolExecutionResult))
.build());
}
else {
// Send the tool execution result back to the model.
return this.internalStream(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()),
response);
}
}).subscribeOn(Schedulers.boundedElastic());
}
else {
return Flux.just(response);
}
})
.doOnError(observation::error)
.doFinally(s -> observation.stop())
.contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation));
// @formatter:on
return new MessageAggregator().aggregate(flux, observationContext::setResponse);
});
}
private Generation buildGeneration(Choice choice, Map<String, Object> metadata) {
List<AssistantMessage.ToolCall> toolCalls = choice.message().toolCalls() == null ? List.of()
: choice.message()
.toolCalls()
.stream()
.map(toolCall -> new AssistantMessage.ToolCall(toolCall.id(), "function",
toolCall.function().name(), toolCall.function().arguments()))
.toList();
String finishReason = (choice.finishReason() != null ? choice.finishReason().name() : "");
var generationMetadataBuilder = ChatGenerationMetadata.builder().finishReason(finishReason);
String textContent = choice.message().content();
String reasoningContent = choice.message().reasoningContent();
DeepSeekAssistantMessage assistantMessage = new DeepSeekAssistantMessage(textContent, reasoningContent,
metadata, toolCalls);
return new Generation(assistantMessage, generationMetadataBuilder.build());
}
private ChatResponseMetadata from(DeepSeekApi.ChatCompletion result, Usage usage) {
Assert.notNull(result, "DeepSeek ChatCompletionResult must not be null");
var builder = ChatResponseMetadata.builder()
.id(result.id() != null ? result.id() : "")
.usage(usage)
.model(result.model() != null ? result.model() : "")
.keyValue("created", result.created() != null ? result.created() : 0L)
.keyValue("system-fingerprint", result.systemFingerprint() != null ? result.systemFingerprint() : "");
return builder.build();
}
private ChatResponseMetadata from(ChatResponseMetadata chatResponseMetadata, Usage usage) {
Assert.notNull(chatResponseMetadata, "DeepSeek ChatResponseMetadata must not be null");
var builder = ChatResponseMetadata.builder()
.id(chatResponseMetadata.getId() != null ? chatResponseMetadata.getId() : "")
.usage(usage)
.model(chatResponseMetadata.getModel() != null ? chatResponseMetadata.getModel() : "");
return builder.build();
}
/**
* Convert the ChatCompletionChunk into a ChatCompletion. The Usage is set to null.
* @param chunk the ChatCompletionChunk to convert
* @return the ChatCompletion
*/
private DeepSeekApi.ChatCompletion chunkToChatCompletion(DeepSeekApi.ChatCompletionChunk chunk) {
List<Choice> choices = chunk.choices()
.stream()
.map(chunkChoice -> new Choice(chunkChoice.finishReason(), chunkChoice.index(), chunkChoice.delta(),
chunkChoice.logprobs()))
.toList();
return new DeepSeekApi.ChatCompletion(chunk.id(), choices, chunk.created(), chunk.model(), chunk.serviceTier(),
chunk.systemFingerprint(), chunk.usage());
}
private DefaultUsage getDefaultUsage(DeepSeekApi.Usage usage) {
return new DefaultUsage(usage.promptTokens(), usage.completionTokens(), usage.totalTokens(), usage);
}
Prompt buildRequestPrompt(Prompt prompt) {
DeepSeekChatOptions runtimeOptions = null;
if (prompt.getOptions() != null) {
if (prompt.getOptions() instanceof ToolCallingChatOptions toolCallingChatOptions) {
runtimeOptions = ModelOptionsUtils.copyToTarget(toolCallingChatOptions, ToolCallingChatOptions.class,
DeepSeekChatOptions.class);
}
else {
runtimeOptions = ModelOptionsUtils.copyToTarget(prompt.getOptions(), ChatOptions.class,
DeepSeekChatOptions.class);
}
}
DeepSeekChatOptions requestOptions = ModelOptionsUtils.merge(runtimeOptions, this.defaultOptions,
DeepSeekChatOptions.class);
if (runtimeOptions != null) {
requestOptions.setInternalToolExecutionEnabled(
ModelOptionsUtils.mergeOption(runtimeOptions.getInternalToolExecutionEnabled(),
this.defaultOptions.getInternalToolExecutionEnabled()));
requestOptions.setToolNames(ToolCallingChatOptions.mergeToolNames(runtimeOptions.getToolNames(),
this.defaultOptions.getToolNames()));
requestOptions.setToolCallbacks(ToolCallingChatOptions.mergeToolCallbacks(runtimeOptions.getToolCallbacks(),
this.defaultOptions.getToolCallbacks()));
requestOptions.setToolContext(ToolCallingChatOptions.mergeToolContext(runtimeOptions.getToolContext(),
this.defaultOptions.getToolContext()));
}
else {
requestOptions.setInternalToolExecutionEnabled(this.defaultOptions.getInternalToolExecutionEnabled());
requestOptions.setToolNames(this.defaultOptions.getToolNames());
requestOptions.setToolCallbacks(this.defaultOptions.getToolCallbacks());
requestOptions.setToolContext(this.defaultOptions.getToolContext());
}
ToolCallingChatOptions.validateToolCallbacks(requestOptions.getToolCallbacks());
return new Prompt(prompt.getInstructions(), requestOptions);
}
/**
* Accessible for testing.
*/
ChatCompletionRequest createRequest(Prompt prompt, boolean stream) {
List<ChatCompletionMessage> chatCompletionMessages = prompt.getInstructions().stream().map(message -> {
if (message.getMessageType() == MessageType.USER || message.getMessageType() == MessageType.SYSTEM) {
return List.of(new ChatCompletionMessage(message.getText(),
ChatCompletionMessage.Role.valueOf(message.getMessageType().name())));
}
else if (message.getMessageType() == MessageType.ASSISTANT) {
var assistantMessage = (AssistantMessage) message;
List<ToolCall> toolCalls = null;
if (!CollectionUtils.isEmpty(assistantMessage.getToolCalls())) {
toolCalls = assistantMessage.getToolCalls().stream().map(toolCall -> {
var function = new ChatCompletionFunction(toolCall.name(), toolCall.arguments());
return new ToolCall(toolCall.id(), toolCall.type(), function);
}).toList();
}
Boolean isPrefixAssistantMessage = null;
if (message instanceof DeepSeekAssistantMessage
&& Boolean.TRUE.equals(((DeepSeekAssistantMessage) message).getPrefix())) {
isPrefixAssistantMessage = true;
}
return List.of(new ChatCompletionMessage(assistantMessage.getText(),
ChatCompletionMessage.Role.ASSISTANT, null, null, toolCalls, isPrefixAssistantMessage, null));
}
else if (message.getMessageType() == MessageType.TOOL) {
ToolResponseMessage toolMessage = (ToolResponseMessage) message;
toolMessage.getResponses()
.forEach(response -> Assert.isTrue(response.id() != null, "ToolResponseMessage must have an id"));
return toolMessage.getResponses()
.stream()
.map(tr -> new ChatCompletionMessage(tr.responseData(), ChatCompletionMessage.Role.TOOL, tr.name(),
tr.id(), null))
.toList();
}
else {
throw new IllegalArgumentException("Unsupported message type: " + message.getMessageType());
}
}).flatMap(List::stream).toList();
ChatCompletionRequest request = new ChatCompletionRequest(chatCompletionMessages, stream);
DeepSeekChatOptions requestOptions = (DeepSeekChatOptions) prompt.getOptions();
request = ModelOptionsUtils.merge(requestOptions, request, ChatCompletionRequest.class);
// Add the tool definitions to the request's tools parameter.
List<ToolDefinition> toolDefinitions = this.toolCallingManager.resolveToolDefinitions(requestOptions);
if (!CollectionUtils.isEmpty(toolDefinitions)) {
request = ModelOptionsUtils.merge(
DeepSeekChatOptions.builder().tools(this.getFunctionTools(toolDefinitions)).build(), request,
ChatCompletionRequest.class);
}
return request;
}
private List<DeepSeekApi.FunctionTool> getFunctionTools(List<ToolDefinition> toolDefinitions) {
return toolDefinitions.stream().map(toolDefinition -> {
var function = new DeepSeekApi.FunctionTool.Function(toolDefinition.description(), toolDefinition.name(),
toolDefinition.inputSchema());
return new DeepSeekApi.FunctionTool(function);
}).toList();
}
private ChatOptions buildRequestOptions(DeepSeekApi.ChatCompletionRequest request) {
return ChatOptions.builder()
.model(request.model())
.frequencyPenalty(request.frequencyPenalty())
.maxTokens(request.maxTokens())
.presencePenalty(request.presencePenalty())
.stopSequences(request.stop())
.temperature(request.temperature())
.topP(request.topP())
.build();
}
@Override
public ChatOptions getDefaultOptions() {
return DeepSeekChatOptions.fromOptions(this.defaultOptions);
}
@Override
public String toString() {
return "DeepSeekChatModel [defaultOptions=" + this.defaultOptions + "]";
}
/**
* Use the provided convention for reporting observation data
* @param observationConvention The provided convention
*/
public void setObservationConvention(ChatModelObservationConvention observationConvention) {
Assert.notNull(observationConvention, "observationConvention cannot be null");
this.observationConvention = observationConvention;
}
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private DeepSeekApi deepSeekApi;
private DeepSeekChatOptions defaultOptions = DeepSeekChatOptions.builder()
.model(DeepSeekApi.DEFAULT_CHAT_MODEL)
.temperature(0.7)
.build();
private ToolCallingManager toolCallingManager;
private ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate = new DefaultToolExecutionEligibilityPredicate();
private RetryTemplate retryTemplate = RetryUtils.DEFAULT_RETRY_TEMPLATE;
private ObservationRegistry observationRegistry = ObservationRegistry.NOOP;
private Builder() {
}
public Builder deepSeekApi(DeepSeekApi deepSeekApi) {
this.deepSeekApi = deepSeekApi;
return this;
}
public Builder defaultOptions(DeepSeekChatOptions defaultOptions) {
this.defaultOptions = defaultOptions;
return this;
}
public Builder toolCallingManager(ToolCallingManager toolCallingManager) {
this.toolCallingManager = toolCallingManager;
return this;
}
public Builder toolExecutionEligibilityPredicate(
ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate) {
this.toolExecutionEligibilityPredicate = toolExecutionEligibilityPredicate;
return this;
}
public Builder retryTemplate(RetryTemplate retryTemplate) {
this.retryTemplate = retryTemplate;
return this;
}
public Builder observationRegistry(ObservationRegistry observationRegistry) {
this.observationRegistry = observationRegistry;
return this;
}
public DeepSeekChatModel build() {
if (this.toolCallingManager != null) {
return new DeepSeekChatModel(this.deepSeekApi, this.defaultOptions, this.toolCallingManager,
this.retryTemplate, this.observationRegistry, this.toolExecutionEligibilityPredicate);
}
return new DeepSeekChatModel(this.deepSeekApi, this.defaultOptions, DEFAULT_TOOL_CALLING_MANAGER,
this.retryTemplate, this.observationRegistry, this.toolExecutionEligibilityPredicate);
}
}
}

View File

@@ -0,0 +1,497 @@
/*
* 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.deepseek;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.ai.deepseek.api.DeepSeekApi;
import org.springframework.ai.deepseek.api.ResponseFormat;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import java.util.*;
/**
* Chat completions options for the DeepSeek chat API.
* <a href="https://platform.deepseek.com/api-docs/api/create-chat-completion">DeepSeek
* chat completion</a>
*
* @author Geng Rong
*/
@JsonInclude(Include.NON_NULL)
public class DeepSeekChatOptions implements ToolCallingChatOptions {
// @formatter:off
/**
* ID of the model to use. You can use either usedeepseek-coder or deepseek-chat.
*/
private @JsonProperty("model") String model;
/**
* Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing
* frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim.
*/
private @JsonProperty("frequency_penalty") Double frequencyPenalty;
/**
* The maximum number of tokens that can be generated in the chat completion.
* The total length of input tokens and generated tokens is limited by the model's context length.
*/
private @JsonProperty("max_tokens") Integer maxTokens;
/**
* Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they
* appear in the text so far, increasing the model's likelihood to talk about new topics.
*/
private @JsonProperty("presence_penalty") Double presencePenalty;
/**
* An object specifying the format that the model must output. Setting to { "type":
* "json_object" } enables JSON mode, which guarantees the message the model generates is valid JSON.
*/
private @JsonProperty("response_format") ResponseFormat responseFormat;
/**
* A string or a list containing up to 4 strings, upon encountering these words, the API will cease generating more tokens.
*/
private @JsonProperty("stop") List<String> stop;
/**
* What sampling temperature to use, between 0 and 2.
* Higher values like 0.8 will make the output more random,
* while lower values like 0.2 will make it more focused and deterministic.
* We generally recommend altering this or top_p but not both.
*/
private @JsonProperty("temperature") Double temperature;
/**
* An alternative to sampling with temperature, called nucleus sampling,
* where the model considers the results of the tokens with top_p probability mass.
* So 0.1 means only the tokens comprising the top 10% probability mass are considered.
* We generally recommend altering this or temperature but not both.
*/
private @JsonProperty("top_p") Double topP;
/**
* Whether to return log probabilities of the output tokens or not.
* If true, returns the log probabilities of each output token returned in the content of message.
*/
private @JsonProperty("logprobs") Boolean logprobs;
/**
* An integer between 0 and 20 specifying the number of most likely tokens to return at each token position,
* each with an associated log probability. logprobs must be set to true if this parameter is used.
*/
private @JsonProperty("top_logprobs") Integer topLogprobs;
private @JsonProperty("tools") List<DeepSeekApi.FunctionTool> tools;
/**
* Controls which (if any) function is called by the model. none means the model will
* not call a function and instead generates a message. auto means the model can pick
* between generating a message or calling a function. Specifying a particular
* function via {"type: "function", "function": {"name": "my_function"}} forces the
* model to call that function. none is the default when no functions are present.
* auto is the default if functions are present. Use the
* {@link DeepSeekApi.ChatCompletionRequest.ToolChoiceBuilder} to create a tool choice
* object.
*/
private @JsonProperty("tool_choice") Object toolChoice;
/**
* Whether to enable the tool execution lifecycle internally in ChatModel.
*/
@JsonIgnore
private Boolean internalToolExecutionEnabled;
/**
* Tool Function Callbacks to register with the ChatModel.
* For Prompt Options the toolCallbacks are automatically enabled for the duration of the prompt execution.
* For Default Options the toolCallbacks are registered but disabled by default. Use the enableFunctions to set the functions
* from the registry to be used by the ChatModel chat completion requests.
*/
@JsonIgnore
private List<ToolCallback> toolCallbacks = new ArrayList<>();
/**
* List of functions, identified by their names, to configure for function calling in
* the chat completion requests.
* Functions with those names must exist in the toolCallbacks registry.
* The {@link #toolCallbacks} from the PromptOptions are automatically enabled for the duration of the prompt execution.
* Note that function enabled with the default options are enabled for all chat completion requests. This could impact the token count and the billing.
* If the functions is set in a prompt options, then the enabled functions are only active for the duration of this prompt execution.
*/
@JsonIgnore
private Set<String> toolNames = new HashSet<>();
@JsonIgnore
private Map<String, Object> toolContext = new HashMap<>();;
public static Builder builder() {
return new Builder();
}
@Override
public String getModel() {
return this.model;
}
public void setModel(String model) {
this.model = model;
}
@Override
public Double getFrequencyPenalty() {
return this.frequencyPenalty;
}
public void setFrequencyPenalty(Double frequencyPenalty) {
this.frequencyPenalty = frequencyPenalty;
}
@Override
public Integer getMaxTokens() {
return this.maxTokens;
}
public void setMaxTokens(Integer maxTokens) {
this.maxTokens = maxTokens;
}
@Override
public Double getPresencePenalty() {
return this.presencePenalty;
}
public void setPresencePenalty(Double presencePenalty) {
this.presencePenalty = presencePenalty;
}
public ResponseFormat getResponseFormat() {
return this.responseFormat;
}
public void setResponseFormat(ResponseFormat responseFormat) {
this.responseFormat = responseFormat;
}
@Override
@JsonIgnore
public List<String> getStopSequences() {
return getStop();
}
@JsonIgnore
public void setStopSequences(List<String> stopSequences) {
setStop(stopSequences);
}
public List<String> getStop() {
return this.stop;
}
public void setStop(List<String> stop) {
this.stop = stop;
}
@Override
public Double getTemperature() {
return this.temperature;
}
public void setTemperature(Double temperature) {
this.temperature = temperature;
}
@Override
public Double getTopP() {
return this.topP;
}
public void setTopP(Double topP) {
this.topP = topP;
}
public List<DeepSeekApi.FunctionTool> getTools() {
return this.tools;
}
public void setTools(List<DeepSeekApi.FunctionTool> tools) {
this.tools = tools;
}
public Object getToolChoice() {
return this.toolChoice;
}
public void setToolChoice(Object toolChoice) {
this.toolChoice = toolChoice;
}
@Override
@JsonIgnore
public List<ToolCallback> getToolCallbacks() {
return this.toolCallbacks;
}
@Override
@JsonIgnore
public void setToolCallbacks(List<ToolCallback> toolCallbacks) {
Assert.notNull(toolCallbacks, "toolCallbacks cannot be null");
Assert.noNullElements(toolCallbacks, "toolCallbacks cannot contain null elements");
this.toolCallbacks = toolCallbacks;
}
@Override
@JsonIgnore
public Set<String> getToolNames() {
return this.toolNames;
}
@Override
@JsonIgnore
public void setToolNames(Set<String> toolNames) {
Assert.notNull(toolNames, "toolNames cannot be null");
Assert.noNullElements(toolNames, "toolNames cannot contain null elements");
toolNames.forEach(tool -> Assert.hasText(tool, "toolNames cannot contain empty elements"));
this.toolNames = toolNames;
}
@Override
@Nullable
@JsonIgnore
public Boolean getInternalToolExecutionEnabled() {
return this.internalToolExecutionEnabled;
}
@Override
@JsonIgnore
public void setInternalToolExecutionEnabled(@Nullable Boolean internalToolExecutionEnabled) {
this.internalToolExecutionEnabled = internalToolExecutionEnabled;
}
public Boolean getLogprobs() {
return this.logprobs;
}
public void setLogprobs(Boolean logprobs) {
this.logprobs = logprobs;
}
public Integer getTopLogprobs() {
return this.topLogprobs;
}
public void setTopLogprobs(Integer topLogprobs) {
this.topLogprobs = topLogprobs;
}
@Override
@JsonIgnore
public Integer getTopK() {
return null;
}
@Override
public Map<String, Object> getToolContext() {
return this.toolContext;
}
@Override
public void setToolContext(Map<String, Object> toolContext) {
this.toolContext = toolContext;
}
@Override
public DeepSeekChatOptions copy() {
return DeepSeekChatOptions.fromOptions(this);
}
@Override
public int hashCode() {
return Objects.hash(this.model, this.frequencyPenalty, this.logprobs, this.topLogprobs,
this.maxTokens, this.presencePenalty, this.responseFormat,
this.stop, this.temperature, this.topP, this.tools, this.toolChoice,
this.toolCallbacks, this.toolNames, this.internalToolExecutionEnabled, this.toolContext);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
DeepSeekChatOptions other = (DeepSeekChatOptions) o;
return Objects.equals(this.model, other.model) && Objects.equals(this.frequencyPenalty, other.frequencyPenalty)
&& Objects.equals(this.logprobs, other.logprobs)
&& Objects.equals(this.topLogprobs, other.topLogprobs)
&& Objects.equals(this.maxTokens, other.maxTokens)
&& Objects.equals(this.presencePenalty, other.presencePenalty)
&& Objects.equals(this.responseFormat, other.responseFormat)
&& Objects.equals(this.stop, other.stop) && Objects.equals(this.temperature, other.temperature)
&& Objects.equals(this.topP, other.topP) && Objects.equals(this.tools, other.tools)
&& Objects.equals(this.toolChoice, other.toolChoice)
&& Objects.equals(this.toolCallbacks, other.toolCallbacks)
&& Objects.equals(this.toolNames, other.toolNames)
&& Objects.equals(this.toolContext, other.toolContext)
&& Objects.equals(this.internalToolExecutionEnabled, other.internalToolExecutionEnabled);
}
public static class Builder {
protected DeepSeekChatOptions options;
public Builder() {
this.options = new DeepSeekChatOptions();
}
public Builder(DeepSeekChatOptions options) {
this.options = options;
}
public Builder model(String model) {
this.options.model = model;
return this;
}
public Builder model(DeepSeekApi.ChatModel deepseekAiChatModel) {
this.options.model = deepseekAiChatModel.getName();
return this;
}
public Builder frequencyPenalty(Double frequencyPenalty) {
this.options.frequencyPenalty = frequencyPenalty;
return this;
}
public Builder logprobs(Boolean logprobs) {
this.options.logprobs = logprobs;
return this;
}
public Builder topLogprobs(Integer topLogprobs) {
this.options.topLogprobs = topLogprobs;
return this;
}
public Builder maxTokens(Integer maxTokens) {
this.options.maxTokens = maxTokens;
return this;
}
public Builder presencePenalty(Double presencePenalty) {
this.options.presencePenalty = presencePenalty;
return this;
}
public Builder responseFormat(ResponseFormat responseFormat) {
this.options.responseFormat = responseFormat;
return this;
}
public Builder stop(List<String> stop) {
this.options.stop = stop;
return this;
}
public Builder temperature(Double temperature) {
this.options.temperature = temperature;
return this;
}
public Builder topP(Double topP) {
this.options.topP = topP;
return this;
}
public Builder tools(List<DeepSeekApi.FunctionTool> tools) {
this.options.tools = tools;
return this;
}
public Builder toolChoice(Object toolChoice) {
this.options.toolChoice = toolChoice;
return this;
}
public Builder toolCallbacks(List<ToolCallback> toolCallbacks) {
this.options.setToolCallbacks(toolCallbacks);
return this;
}
public Builder toolCallbacks(ToolCallback... toolCallbacks) {
Assert.notNull(toolCallbacks, "toolCallbacks cannot be null");
this.options.toolCallbacks.addAll(Arrays.asList(toolCallbacks));
return this;
}
public Builder toolNames(Set<String> toolNames) {
Assert.notNull(toolNames, "toolNames cannot be null");
this.options.setToolNames(toolNames);
return this;
}
public Builder toolNames(String... toolNames) {
Assert.notNull(toolNames, "toolNames cannot be null");
this.options.toolNames.addAll(Set.of(toolNames));
return this;
}
public Builder internalToolExecutionEnabled(@Nullable Boolean internalToolExecutionEnabled) {
this.options.setInternalToolExecutionEnabled(internalToolExecutionEnabled);
return this;
}
public Builder toolContext(Map<String, Object> toolContext) {
if (this.options.toolContext == null) {
this.options.toolContext = toolContext;
}
else {
this.options.toolContext.putAll(toolContext);
}
return this;
}
public DeepSeekChatOptions build() {
return this.options;
}
}
public static DeepSeekChatOptions fromOptions(DeepSeekChatOptions fromOptions) {
return DeepSeekChatOptions.builder()
.model(fromOptions.getModel())
.frequencyPenalty(fromOptions.getFrequencyPenalty())
.logprobs(fromOptions.getLogprobs())
.topLogprobs(fromOptions.getTopLogprobs())
.maxTokens(fromOptions.getMaxTokens())
.presencePenalty(fromOptions.getPresencePenalty())
.responseFormat(fromOptions.getResponseFormat())
.stop(fromOptions.getStop() != null ? new ArrayList<>(fromOptions.getStop()) : null)
.temperature(fromOptions.getTemperature())
.topP(fromOptions.getTopP())
.tools(fromOptions.getTools())
.toolChoice(fromOptions.getToolChoice())
.toolCallbacks(
fromOptions.getToolCallbacks() != null ? new ArrayList<>(fromOptions.getToolCallbacks()) : null)
.toolNames(fromOptions.getToolNames() != null ? new HashSet<>(fromOptions.getToolNames()) : null)
.internalToolExecutionEnabled(fromOptions.getInternalToolExecutionEnabled())
.toolContext(fromOptions.getToolContext() != null ? new HashMap<>(fromOptions.getToolContext()) : null)
.build();
}
}

View File

@@ -0,0 +1,42 @@
/*
* 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.deepseek.aot;
import org.springframework.ai.deepseek.api.DeepSeekApi;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import static org.springframework.ai.aot.AiRuntimeHints.findJsonAnnotatedClassesInPackage;
/**
* The DeepSeekRuntimeHints class is responsible for registering runtime hints for
* DeepSeek API classes.
*
* @author Geng Rong
*/
public class DeepSeekRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(@NonNull RuntimeHints hints, @Nullable ClassLoader classLoader) {
var mcs = MemberCategory.values();
for (var tr : findJsonAnnotatedClassesInPackage(DeepSeekApi.class))
hints.reflection().registerType(tr, mcs);
}
}

View File

@@ -0,0 +1,978 @@
/*
* 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.deepseek.api;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.ai.model.ApiKey;
import org.springframework.ai.model.ChatModelDescription;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.model.SimpleApiKey;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.function.Predicate;
import static org.springframework.ai.deepseek.api.common.DeepSeekConstants.*;
/**
* Single class implementation of the DeepSeek Chat Completion API:
* https://platform.deepseek.com/api-docs/api/create-chat-completion
*
* @author Geng Rong
*/
public class DeepSeekApi {
public static final DeepSeekApi.ChatModel DEFAULT_CHAT_MODEL = ChatModel.DEEPSEEK_CHAT;
private static final Predicate<String> SSE_DONE_PREDICATE = "[DONE]"::equals;
private final String completionsPath;
private final String betaPrefixPath;
private final RestClient restClient;
private final WebClient webClient;
private DeepSeekStreamFunctionCallingHelper chunkMerger = new DeepSeekStreamFunctionCallingHelper();
/**
* Create a new chat completion api.
* @param baseUrl api base URL.
* @param apiKey DeepSeek apiKey.
* @param headers the http headers to use.
* @param completionsPath the path to the chat completions endpoint.
* @param betaPrefixPath the prefix path to the beta feature endpoint.
* @param restClientBuilder RestClient builder.
* @param webClientBuilder WebClient builder.
* @param responseErrorHandler Response error handler.
*/
public DeepSeekApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, String> headers, String completionsPath,
String betaPrefixPath, RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder,
ResponseErrorHandler responseErrorHandler) {
Assert.hasText(completionsPath, "Completions Path must not be null");
Assert.hasText(betaPrefixPath, "Beta feature path must not be null");
Assert.notNull(headers, "Headers must not be null");
this.completionsPath = completionsPath;
this.betaPrefixPath = betaPrefixPath;
// @formatter:off
Consumer<HttpHeaders> finalHeaders = h -> {
h.setBearerAuth(apiKey.getValue());
h.setContentType(MediaType.APPLICATION_JSON);
h.addAll(headers);
};
this.restClient = restClientBuilder.baseUrl(baseUrl)
.defaultHeaders(finalHeaders)
.defaultStatusHandler(responseErrorHandler)
.build();
this.webClient = webClientBuilder
.baseUrl(baseUrl)
.defaultHeaders(finalHeaders)
.build(); // @formatter:on
}
/**
* Creates a model response for the given chat conversation.
* @param chatRequest The chat completion request.
* @return Entity response with {@link ChatCompletion} as a body and HTTP status code
* and headers.
*/
public ResponseEntity<ChatCompletion> chatCompletionEntity(ChatCompletionRequest chatRequest) {
Assert.notNull(chatRequest, "The request body can not be null.");
Assert.isTrue(!chatRequest.stream(), "Request must set the stream property to false.");
return this.restClient.post()
.uri(this.getEndpoint(chatRequest))
.body(chatRequest)
.retrieve()
.toEntity(ChatCompletion.class);
}
/**
* Creates a streaming chat response for the given chat conversation.
* @param chatRequest The chat completion request. Must have the stream property set
* to true.
* @return Returns a {@link Flux} stream from chat completion chunks.
*/
public Flux<ChatCompletionChunk> chatCompletionStream(ChatCompletionRequest chatRequest) {
return chatCompletionStream(chatRequest, new LinkedMultiValueMap<>());
}
/**
* Creates a streaming chat response for the given chat conversation.
* @param chatRequest The chat completion request. Must have the stream property set
* to true.
* @param additionalHttpHeader Optional, additional HTTP headers to be added to the
* request.
* @return Returns a {@link Flux} stream from chat completion chunks.
*/
public Flux<ChatCompletionChunk> chatCompletionStream(ChatCompletionRequest chatRequest,
MultiValueMap<String, String> additionalHttpHeader) {
Assert.notNull(chatRequest, "The request body can not be null.");
Assert.isTrue(chatRequest.stream(), "Request must set the stream property to true.");
AtomicBoolean isInsideTool = new AtomicBoolean(false);
return this.webClient.post()
.uri(this.getEndpoint(chatRequest))
.headers(headers -> headers.addAll(additionalHttpHeader))
.body(Mono.just(chatRequest), ChatCompletionRequest.class)
.retrieve()
.bodyToFlux(String.class)
// cancels the flux stream after the "[DONE]" is received.
.takeUntil(SSE_DONE_PREDICATE)
// filters out the "[DONE]" message.
.filter(SSE_DONE_PREDICATE.negate())
.map(content -> ModelOptionsUtils.jsonToObject(content, ChatCompletionChunk.class))
// Detect is the chunk is part of a streaming function call.
.map(chunk -> {
if (this.chunkMerger.isStreamingToolFunctionCall(chunk)) {
isInsideTool.set(true);
}
return chunk;
})
// Group all chunks belonging to the same function call.
// Flux<ChatCompletionChunk> -> Flux<Flux<ChatCompletionChunk>>
.windowUntil(chunk -> {
if (isInsideTool.get() && this.chunkMerger.isStreamingToolFunctionCallFinish(chunk)) {
isInsideTool.set(false);
return true;
}
return !isInsideTool.get();
})
// Merging the window chunks into a single chunk.
// Reduce the inner Flux<ChatCompletionChunk> window into a single
// Mono<ChatCompletionChunk>,
// Flux<Flux<ChatCompletionChunk>> -> Flux<Mono<ChatCompletionChunk>>
.concatMapIterable(window -> {
Mono<ChatCompletionChunk> monoChunk = window.reduce(
new ChatCompletionChunk(null, null, null, null, null, null, null, null),
(previous, current) -> this.chunkMerger.merge(previous, current));
return List.of(monoChunk);
})
// Flux<Mono<ChatCompletionChunk>> -> Flux<ChatCompletionChunk>
.flatMap(mono -> mono);
}
/**
* DeepSeek Chat Completion
* <a href="https://api-docs.deepseek.com/quick_start/pricing">Models</a>
*/
public enum ChatModel implements ChatModelDescription {
/**
* The backend model of deepseek-chat has been updated to DeepSeek-V3, you can
* access DeepSeek-V3 without modification to the model name. The open-source
* DeepSeek-V3 model supports 128K context window, and DeepSeek-V3 on API/Web
* supports 64K context window. Context window: 64k tokens
*/
DEEPSEEK_CHAT("deepseek-chat"),
/**
* deepseek-reasoner is a reasoning model developed by DeepSeek. Before delivering
* the final answer, the model first generates a Chain of Thought (CoT) to enhance
* the accuracy of its responses. Our API provides users with access to the CoT
* content generated by deepseek-reasoner, enabling them to view, display, and
* distill it.
*/
DEEPSEEK_REASONER("deepseek-reasoner");
public final String value;
ChatModel(String value) {
this.value = value;
}
public String getValue() {
return value;
}
@Override
public String getName() {
return value;
}
}
/**
* The reason the model stopped generating tokens.
*/
public enum ChatCompletionFinishReason {
/**
* The model hit a natural stop point or a provided stop sequence.
*/
@JsonProperty("stop")
STOP,
/**
* The maximum number of tokens specified in the request was reached.
*/
@JsonProperty("length")
LENGTH,
/**
* The content was omitted due to a flag from our content filters.
*/
@JsonProperty("content_filter")
CONTENT_FILTER,
/**
* The model called a tool.
*/
@JsonProperty("tool_calls")
TOOL_CALLS,
/**
* Only for compatibility with Mistral AI API.
*/
@JsonProperty("tool_call")
TOOL_CALL
}
/**
* Represents a tool the model may call. Currently, only functions are supported as a
* tool.
*/
@JsonInclude(Include.NON_NULL)
public static class FunctionTool {
/**
* The type of the tool. Currently, only 'function' is supported.
*/
@JsonProperty("type")
private Type type = Type.FUNCTION;
/**
* The function definition.
*/
@JsonProperty("function")
private Function function;
public FunctionTool() {
}
/**
* Create a tool of type 'function' and the given function definition.
* @param type the tool type
* @param function function definition
*/
public FunctionTool(Type type, Function function) {
this.type = type;
this.function = function;
}
/**
* Create a tool of type 'function' and the given function definition.
* @param function function definition.
*/
public FunctionTool(Function function) {
this(Type.FUNCTION, function);
}
public Type getType() {
return this.type;
}
public Function getFunction() {
return this.function;
}
public void setType(Type type) {
this.type = type;
}
public void setFunction(Function function) {
this.function = function;
}
/**
* Create a tool of type 'function' and the given function definition.
*/
public enum Type {
/**
* Function tool type.
*/
@JsonProperty("function")
FUNCTION
}
/**
* Function definition.
*/
@JsonInclude(Include.NON_NULL)
public static class Function {
@JsonProperty("description")
private String description;
@JsonProperty("name")
private String name;
@JsonProperty("parameters")
private Map<String, Object> parameters;
@JsonProperty("strict")
Boolean strict;
@JsonIgnore
private String jsonSchema;
/**
* NOTE: Required by Jackson, JSON deserialization!
*/
@SuppressWarnings("unused")
private Function() {
}
/**
* Create tool function definition.
* @param description A description of what the function does, used by the
* model to choose when and how to call the function.
* @param name The name of the function to be called. Must be a-z, A-Z, 0-9,
* or contain underscores and dashes, with a maximum length of 64.
* @param parameters The parameters the functions accepts, described as a JSON
* Schema object. To describe a function that accepts no parameters, provide
* the value {"type": "object", "properties": {}}.
* @param strict Whether to enable strict schema adherence when generating the
* function call. If set to true, the model will follow the exact schema
* defined in the parameters field. Only a subset of JSON Schema is supported
* when strict is true.
*/
public Function(String description, String name, Map<String, Object> parameters, Boolean strict) {
this.description = description;
this.name = name;
this.parameters = parameters;
this.strict = strict;
}
/**
* Create tool function definition.
* @param description tool function description.
* @param name tool function name.
* @param jsonSchema tool function schema as json.
*/
public Function(String description, String name, String jsonSchema) {
this(description, name, ModelOptionsUtils.jsonToMap(jsonSchema), null);
}
public String getDescription() {
return this.description;
}
public String getName() {
return this.name;
}
public Map<String, Object> getParameters() {
return this.parameters;
}
public void setDescription(String description) {
this.description = description;
}
public void setName(String name) {
this.name = name;
}
public void setParameters(Map<String, Object> parameters) {
this.parameters = parameters;
}
public Boolean getStrict() {
return this.strict;
}
public void setStrict(Boolean strict) {
this.strict = strict;
}
public String getJsonSchema() {
return this.jsonSchema;
}
public void setJsonSchema(String jsonSchema) {
this.jsonSchema = jsonSchema;
if (jsonSchema != null) {
this.parameters = ModelOptionsUtils.jsonToMap(jsonSchema);
}
}
}
}
/**
* Creates a model response for the given chat conversation.
*
* @param messages A list of messages comprising the conversation so far.
* @param model ID of the model to use.
* @param frequencyPenalty Number between -2.0 and 2.0. Positive values penalize new
* tokens based on their existing frequency in the text so far, decreasing the model's
* likelihood to repeat the same line verbatim.
* @param maxTokens The maximum number of tokens that can be generated in the chat
* completion. This value can be used to control costs for text generated via API.
* This value is now deprecated in favor of max_completion_tokens, and is not
* compatible with o1 series models.
* @param presencePenalty Number between -2.0 and 2.0. Positive values penalize new
* tokens based on whether they appear in the text so far, increasing the model's
* likelihood to talk about new topics.
* @param responseFormat An object specifying the format that the model must output.
* Setting to { "type": "json_object" } enables JSON mode, which guarantees the
* message the model generates is valid JSON.
* @param stop A string or a list containing up to 4 strings, upon encountering these
* words, the API will cease generating more tokens.
* @param stream If set, partial message deltas will be sent.Tokens will be sent as
* data-only server-sent events as they become available, with the stream terminated
* by a data: [DONE] message.
* @param temperature What sampling temperature to use, between 0 and 2. Higher values
* like 0.8 will make the output more random, while lower values like 0.2 will make it
* more focused and deterministic. We generally recommend altering this or top_p but
* not both.
* @param topP An alternative to sampling with temperature, called nucleus sampling,
* where the model considers the results of the tokens with top_p probability mass. So
* 0.1 means only the tokens comprising the top 10% probability mass are considered.
* We generally recommend altering this or temperature but not both.
* @param logprobs Whether to return log probabilities of the output tokens or not. If
* true, returns the log probabilities of each output token returned in the content of
* message.
* @param topLogprobs An integer between 0 and 20 specifying the number of most likely
* tokens to return at each token position, each with an associated log probability.
* logprobs must be set to true if this parameter is used.
* @param tools A list of tools the model may call. Currently, only functions are
* supported as a tool. Use this to provide a list of functions the model may generate
* JSON inputs for.
* @param toolChoice Controls which (if any) function is called by the model. none
* means the model will not call a function and instead generates a message. auto
* means the model can pick between generating a message or calling a function.
* Specifying a particular function via {"type: "function", "function": {"name":
* "my_function"}} forces the model to call that function. none is the default when no
* functions are present. auto is the default if functions are present. Use the
* {@link ToolChoiceBuilder} to create the tool choice value.
*/
@JsonInclude(Include.NON_NULL)
public record ChatCompletionRequest(// @formatter:off
@JsonProperty("messages") List<ChatCompletionMessage> messages,
@JsonProperty("model") String model,
@JsonProperty("frequency_penalty") Double frequencyPenalty,
@JsonProperty("max_tokens") Integer maxTokens, // Use maxCompletionTokens instead
@JsonProperty("presence_penalty") Double presencePenalty,
@JsonProperty("response_format") ResponseFormat responseFormat,
@JsonProperty("stop") List<String> stop,
@JsonProperty("stream") Boolean stream,
@JsonProperty("temperature") Double temperature,
@JsonProperty("top_p") Double topP,
@JsonProperty("logprobs") Boolean logprobs,
@JsonProperty("top_logprobs") Integer topLogprobs,
@JsonProperty("tools") List<FunctionTool> tools,
@JsonProperty("tool_choice") Object toolChoice)
{
/**
* Shortcut constructor for a chat completion request with the given messages for streaming.
*
* @param messages A list of messages comprising the conversation so far.
* @param stream If set, partial message deltas will be sent.Tokens will be sent as data-only server-sent events
* as they become available, with the stream terminated by a data: [DONE] message.
*/
public ChatCompletionRequest(List<ChatCompletionMessage> messages, Boolean stream) {
this(messages, null, null, null, null, null,
null, stream, null, null, null, null, null, null);
}
/**
* Shortcut constructor for a chat completion request with the given messages, model and temperature.
*
* @param messages A list of messages comprising the conversation so far.
* @param model ID of the model to use.
* @param temperature What sampling temperature to use, between 0 and 1.
*/
public ChatCompletionRequest(List<ChatCompletionMessage> messages, String model, Double temperature) {
this(messages, model, null,
null, null, null, null, false, temperature, null,
null, null, null,null);
}
/**
* Shortcut constructor for a chat completion request with the given messages, model, temperature and control for streaming.
*
* @param messages A list of messages comprising the conversation so far.
* @param model ID of the model to use.
* @param temperature What sampling temperature to use, between 0 and 1.
* @param stream If set, partial message deltas will be sent.Tokens will be sent as data-only server-sent events
* as they become available, with the stream terminated by a data: [DONE] message.
*/
public ChatCompletionRequest(List<ChatCompletionMessage> messages, String model, Double temperature, boolean stream) {
this(messages, model, null,
null, null, null, null, stream, temperature, null,
null, null, null,null);
}
/**
* Helper factory that creates a tool_choice of type 'none', 'auto' or selected function by name.
*/
public static class ToolChoiceBuilder {
/**
* Model can pick between generating a message or calling a function.
*/
public static final String AUTO = "auto";
/**
* Model will not call a function and instead generates a message
*/
public static final String NONE = "none";
/**
* Specifying a particular function forces the model to call that function.
*/
public static Object FUNCTION(String functionName) {
return Map.of("type", "function", "function", Map.of("name", functionName));
}
}
} // @formatter:on
/**
* Message comprising the conversation.
*
* @param rawContent The contents of the message. The message content is always a
* {@link String}.
* @param role The role of the messages author. Could be one of the {@link Role}
* types.
* @param name An optional name for the participant. Provides the model information to
* differentiate between participants of the same role. In case of Function calling,
* the name is the function name that the message is responding to.
* @param toolCallId Tool call that this message is responding to. Only applicable for
* the {@link Role#TOOL} role and null otherwise.
* @param toolCalls The tool calls generated by the model, such as function calls.
* Applicable only for {@link Role#ASSISTANT} role and null otherwise.
*/
@JsonInclude(Include.NON_NULL)
public record ChatCompletionMessage(// @formatter:off
@JsonProperty("content") Object rawContent,
@JsonProperty("role") Role role,
@JsonProperty("name") String name,
@JsonProperty("tool_call_id") String toolCallId,
@JsonProperty("tool_calls")
@JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) List<ToolCall> toolCalls,
@JsonProperty("prefix") Boolean prefix,
@JsonProperty("reasoning_content") String reasoningContent
) { // @formatter:on
/**
* Create a chat completion message with the given content and role. All other
* fields are null.
* @param content The contents of the message.
* @param role The role of the author of this message.
*/
public ChatCompletionMessage(Object content, Role role) {
this(content, role, null, null, null, null, null);
}
/**
* Create a chat completion message with the given content and role. All other
* fields are null.
* @param content The contents of the message.
* @param role The role of the author of this message.
* @param name The name of the author of this message.
* @param toolCallId The id of the tool call.
* @param toolCalls The tool calls generated by the model, such as function calls.
*/
public ChatCompletionMessage(Object content, Role role, String name, String toolCallId,
List<ToolCall> toolCalls) {
this(content, role, name, toolCallId, toolCalls, null, null);
}
/**
* Get message content as String.
*/
public String content() {
if (this.rawContent == null) {
return null;
}
if (this.rawContent instanceof String text) {
return text;
}
throw new IllegalStateException("The content is not a string!");
}
/**
* The role of the author of this message.
*/
public enum Role {
/**
* System message.
*/
@JsonProperty("system")
SYSTEM,
/**
* User message.
*/
@JsonProperty("user")
USER,
/**
* Assistant message.
*/
@JsonProperty("assistant")
ASSISTANT,
/**
* Tool message.
*/
@JsonProperty("tool")
TOOL
}
/**
* The relevant tool call.
*
* @param index The index of the tool call in the list of tool calls. Required in
* case of streaming.
* @param id The ID of the tool call. This ID must be referenced when you submit
* the tool outputs in using the Submit tool outputs to run endpoint.
* @param type The type of tool call the output is required for. For now, this is
* always function.
* @param function The function definition.
*/
@JsonInclude(Include.NON_NULL)
public record ToolCall(// @formatter:off
@JsonProperty("index") Integer index,
@JsonProperty("id") String id,
@JsonProperty("type") String type,
@JsonProperty("function") ChatCompletionFunction function) { // @formatter:on
public ToolCall(String id, String type, ChatCompletionFunction function) {
this(null, id, type, function);
}
}
/**
* The function definition.
*
* @param name The name of the function.
* @param arguments The arguments that the model expects you to pass to the
* function.
*/
@JsonInclude(Include.NON_NULL)
public record ChatCompletionFunction(// @formatter:off
@JsonProperty("name") String name,
@JsonProperty("arguments") String arguments) { // @formatter:on
}
}
/**
* Represents a chat completion response returned by model, based on the provided
* input.
*
* @param id A unique identifier for the chat completion.
* @param choices A list of chat completion choices. Can be more than one if n is
* greater than 1.
* @param created The Unix timestamp (in seconds) of when the chat completion was
* created.
* @param model The model used for the chat completion.
* @param systemFingerprint This fingerprint represents the backend configuration that
* the model runs with. Can be used in conjunction with the seed request parameter to
* understand when backend changes have been made that might impact determinism.
* @param object The object type, which is always chat.completion.
* @param usage Usage statistics for the completion request.
*/
@JsonInclude(Include.NON_NULL)
public record ChatCompletion(// @formatter:off
@JsonProperty("id") String id,
@JsonProperty("choices") List<Choice> choices,
@JsonProperty("created") Long created,
@JsonProperty("model") String model,
@JsonProperty("system_fingerprint") String systemFingerprint,
@JsonProperty("object") String object,
@JsonProperty("usage") Usage usage
) { // @formatter:on
/**
* Chat completion choice.
*
* @param finishReason The reason the model stopped generating tokens.
* @param index The index of the choice in the list of choices.
* @param message A chat completion message generated by the model.
* @param logprobs Log probability information for the choice.
*/
@JsonInclude(Include.NON_NULL)
public record Choice(// @formatter:off
@JsonProperty("finish_reason") ChatCompletionFinishReason finishReason,
@JsonProperty("index") Integer index,
@JsonProperty("message") ChatCompletionMessage message,
@JsonProperty("logprobs") LogProbs logprobs) { // @formatter:on
}
}
/**
* Log probability information for the choice.
*
* @param content A list of message content tokens with log probability information.
* @param refusal A list of message refusal tokens with log probability information.
*/
@JsonInclude(Include.NON_NULL)
public record LogProbs(@JsonProperty("content") List<Content> content,
@JsonProperty("refusal") List<Content> refusal) {
/**
* Message content tokens with log probability information.
*
* @param token The token.
* @param logprob The log probability of the token.
* @param probBytes A list of integers representing the UTF-8 bytes representation
* of the token. Useful in instances where characters are represented by multiple
* tokens and their byte representations must be combined to generate the correct
* text representation. Can be null if there is no bytes representation for the
* token.
* @param topLogprobs List of the most likely tokens and their log probability, at
* this token position. In rare cases, there may be fewer than the number of
* requested top_logprobs returned.
*/
@JsonInclude(Include.NON_NULL)
public record Content(// @formatter:off
@JsonProperty("token") String token,
@JsonProperty("logprob") Float logprob,
@JsonProperty("bytes") List<Integer> probBytes,
@JsonProperty("top_logprobs") List<TopLogProbs> topLogprobs) { // @formatter:on
/**
* The most likely tokens and their log probability, at this token position.
*
* @param token The token.
* @param logprob The log probability of the token.
* @param probBytes A list of integers representing the UTF-8 bytes
* representation of the token. Useful in instances where characters are
* represented by multiple tokens and their byte representations must be
* combined to generate the correct text representation. Can be null if there
* is no bytes representation for the token.
*/
@JsonInclude(Include.NON_NULL)
public record TopLogProbs(// @formatter:off
@JsonProperty("token") String token,
@JsonProperty("logprob") Float logprob,
@JsonProperty("bytes") List<Integer> probBytes) { // @formatter:on
}
}
}
// Embeddings API
/**
* Usage statistics for the completion request.
*
* @param completionTokens Number of tokens in the generated completion. Only
* applicable for completion requests.
* @param promptTokens Number of tokens in the prompt.
* @param totalTokens Total number of tokens used in the request (prompt +
* completion).
* @param promptTokensDetails Breakdown of tokens used in the prompt.
*/
@JsonInclude(Include.NON_NULL)
public record Usage(// @formatter:off
@JsonProperty("completion_tokens") Integer completionTokens,
@JsonProperty("prompt_tokens") Integer promptTokens,
@JsonProperty("total_tokens") Integer totalTokens,
@JsonProperty("prompt_tokens_details") PromptTokensDetails promptTokensDetails) { // @formatter:on
public Usage(Integer completionTokens, Integer promptTokens, Integer totalTokens) {
this(completionTokens, promptTokens, totalTokens, null);
}
/**
* Breakdown of tokens used in the prompt
*
* @param cachedTokens Cached tokens present in the prompt.
*/
@JsonInclude(Include.NON_NULL)
public record PromptTokensDetails(// @formatter:off
@JsonProperty("cached_tokens") Integer cachedTokens) { // @formatter:on
}
}
/**
* Represents a streamed chunk of a chat completion response returned by model, based
* on the provided input.
*
* @param id A unique identifier for the chat completion. Each chunk has the same ID.
* @param choices A list of chat completion choices. Can be more than one if n is
* greater than 1.
* @param created The Unix timestamp (in seconds) of when the chat completion was
* created. Each chunk has the same timestamp.
* @param model The model used for the chat completion.
* @param serviceTier The service tier used for processing the request. This field is
* only included if the service_tier parameter is specified in the request.
* @param systemFingerprint This fingerprint represents the backend configuration that
* the model runs with. Can be used in conjunction with the seed request parameter to
* understand when backend changes have been made that might impact determinism.
* @param object The object type, which is always 'chat.completion.chunk'.
* @param usage Usage statistics for the completion request. Present in the last chunk
* only if the StreamOptions.includeUsage is set to true.
*/
@JsonInclude(Include.NON_NULL)
public record ChatCompletionChunk(// @formatter:off
@JsonProperty("id") String id,
@JsonProperty("choices") List<ChunkChoice> choices,
@JsonProperty("created") Long created,
@JsonProperty("model") String model,
@JsonProperty("service_tier") String serviceTier,
@JsonProperty("system_fingerprint") String systemFingerprint,
@JsonProperty("object") String object,
@JsonProperty("usage") Usage usage) { // @formatter:on
/**
* Chat completion choice.
*
* @param finishReason The reason the model stopped generating tokens.
* @param index The index of the choice in the list of choices.
* @param delta A chat completion delta generated by streamed model responses.
* @param logprobs Log probability information for the choice.
*/
@JsonInclude(Include.NON_NULL)
public record ChunkChoice(// @formatter:off
@JsonProperty("finish_reason") ChatCompletionFinishReason finishReason,
@JsonProperty("index") Integer index,
@JsonProperty("delta") ChatCompletionMessage delta,
@JsonProperty("logprobs") LogProbs logprobs) { // @formatter:on
}
}
private String getEndpoint(ChatCompletionRequest request) {
boolean isPrefix = request.messages.stream()
.map(ChatCompletionMessage::prefix)
.filter(Objects::nonNull)
.anyMatch(prefix -> prefix);
String endpointPrefix = isPrefix ? betaPrefixPath : "";
return endpointPrefix + completionsPath;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String baseUrl = DEFAULT_BASE_URL;
private ApiKey apiKey;
private MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
private String completionsPath = DEFAULT_COMPLETIONS_PATH;
private String betaPrefixPath = DEFAULT_BETA_PATH;
private RestClient.Builder restClientBuilder = RestClient.builder();
private WebClient.Builder webClientBuilder = WebClient.builder();
private ResponseErrorHandler responseErrorHandler = RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER;
public Builder baseUrl(String baseUrl) {
Assert.hasText(baseUrl, "baseUrl cannot be null or empty");
this.baseUrl = baseUrl;
return this;
}
public Builder apiKey(ApiKey apiKey) {
Assert.notNull(apiKey, "apiKey cannot be null");
this.apiKey = apiKey;
return this;
}
public Builder apiKey(String simpleApiKey) {
Assert.notNull(simpleApiKey, "simpleApiKey cannot be null");
this.apiKey = new SimpleApiKey(simpleApiKey);
return this;
}
public Builder headers(MultiValueMap<String, String> headers) {
Assert.notNull(headers, "headers cannot be null");
this.headers = headers;
return this;
}
public Builder completionsPath(String completionsPath) {
Assert.hasText(completionsPath, "completionsPath cannot be null or empty");
this.completionsPath = completionsPath;
return this;
}
public Builder betaPrefixPath(String betaPrefixPath) {
Assert.hasText(betaPrefixPath, "betaPrefixPath cannot be null or empty");
this.betaPrefixPath = betaPrefixPath;
return this;
}
public Builder restClientBuilder(RestClient.Builder restClientBuilder) {
Assert.notNull(restClientBuilder, "restClientBuilder cannot be null");
this.restClientBuilder = restClientBuilder;
return this;
}
public Builder webClientBuilder(WebClient.Builder webClientBuilder) {
Assert.notNull(webClientBuilder, "webClientBuilder cannot be null");
this.webClientBuilder = webClientBuilder;
return this;
}
public Builder responseErrorHandler(ResponseErrorHandler responseErrorHandler) {
Assert.notNull(responseErrorHandler, "responseErrorHandler cannot be null");
this.responseErrorHandler = responseErrorHandler;
return this;
}
public DeepSeekApi build() {
Assert.notNull(this.apiKey, "apiKey must be set");
return new DeepSeekApi(this.baseUrl, this.apiKey, this.headers, this.completionsPath, this.betaPrefixPath,
this.restClientBuilder, this.webClientBuilder, this.responseErrorHandler);
}
}
}

View File

@@ -0,0 +1,176 @@
/*
* 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.deepseek.api;
import org.springframework.ai.deepseek.api.DeepSeekApi.ChatCompletionChunk;
import org.springframework.ai.deepseek.api.DeepSeekApi.ChatCompletionChunk.ChunkChoice;
import org.springframework.ai.deepseek.api.DeepSeekApi.ChatCompletionFinishReason;
import org.springframework.ai.deepseek.api.DeepSeekApi.ChatCompletionMessage;
import org.springframework.ai.deepseek.api.DeepSeekApi.ChatCompletionMessage.ChatCompletionFunction;
import org.springframework.ai.deepseek.api.DeepSeekApi.ChatCompletionMessage.Role;
import org.springframework.ai.deepseek.api.DeepSeekApi.ChatCompletionMessage.ToolCall;
import org.springframework.util.CollectionUtils;
import java.util.ArrayList;
import java.util.List;
/**
* Helper class to support Streaming function calling. It can merge the streamed
* ChatCompletionChunk in case of function calling message.
*
* @author Geng Rong
*/
public class DeepSeekStreamFunctionCallingHelper {
public ChatCompletionChunk merge(ChatCompletionChunk previous, ChatCompletionChunk current) {
if (previous == null) {
return current;
}
String id = (current.id() != null ? current.id() : previous.id());
Long created = (current.created() != null ? current.created() : previous.created());
String model = (current.model() != null ? current.model() : previous.model());
String serviceTier = (current.serviceTier() != null ? current.serviceTier() : previous.serviceTier());
String systemFingerprint = (current.systemFingerprint() != null ? current.systemFingerprint()
: previous.systemFingerprint());
String object = (current.object() != null ? current.object() : previous.object());
DeepSeekApi.Usage usage = (current.usage() != null ? current.usage() : previous.usage());
ChunkChoice previousChoice0 = (CollectionUtils.isEmpty(previous.choices()) ? null : previous.choices().get(0));
ChunkChoice currentChoice0 = (CollectionUtils.isEmpty(current.choices()) ? null : current.choices().get(0));
ChunkChoice choice = merge(previousChoice0, currentChoice0);
List<ChunkChoice> chunkChoices = choice == null ? List.of() : List.of(choice);
return new ChatCompletionChunk(id, chunkChoices, created, model, serviceTier, systemFingerprint, object, usage);
}
private ChunkChoice merge(ChunkChoice previous, ChunkChoice current) {
if (previous == null) {
return current;
}
ChatCompletionFinishReason finishReason = (current.finishReason() != null ? current.finishReason()
: previous.finishReason());
Integer index = (current.index() != null ? current.index() : previous.index());
ChatCompletionMessage message = merge(previous.delta(), current.delta());
DeepSeekApi.LogProbs logprobs = (current.logprobs() != null ? current.logprobs() : previous.logprobs());
return new ChunkChoice(finishReason, index, message, logprobs);
}
private ChatCompletionMessage merge(ChatCompletionMessage previous, ChatCompletionMessage current) {
String content = (current.content() != null ? current.content()
: "" + ((previous.content() != null) ? previous.content() : ""));
Role role = (current.role() != null ? current.role() : previous.role());
role = (role != null ? role : Role.ASSISTANT); // default to ASSISTANT (if null
String name = (current.name() != null ? current.name() : previous.name());
String toolCallId = (current.toolCallId() != null ? current.toolCallId() : previous.toolCallId());
List<ToolCall> toolCalls = new ArrayList<>();
ToolCall lastPreviousTooCall = null;
if (previous.toolCalls() != null) {
lastPreviousTooCall = previous.toolCalls().get(previous.toolCalls().size() - 1);
if (previous.toolCalls().size() > 1) {
toolCalls.addAll(previous.toolCalls().subList(0, previous.toolCalls().size() - 1));
}
}
if (current.toolCalls() != null) {
if (current.toolCalls().size() > 1) {
throw new IllegalStateException("Currently only one tool call is supported per message!");
}
var currentToolCall = current.toolCalls().iterator().next();
if (currentToolCall.id() != null) {
if (lastPreviousTooCall != null) {
toolCalls.add(lastPreviousTooCall);
}
toolCalls.add(currentToolCall);
}
else {
toolCalls.add(merge(lastPreviousTooCall, currentToolCall));
}
}
else {
if (lastPreviousTooCall != null) {
toolCalls.add(lastPreviousTooCall);
}
}
return new ChatCompletionMessage(content, role, name, toolCallId, toolCalls);
}
private ToolCall merge(ToolCall previous, ToolCall current) {
if (previous == null) {
return current;
}
String id = (current.id() != null ? current.id() : previous.id());
String type = (current.type() != null ? current.type() : previous.type());
ChatCompletionFunction function = merge(previous.function(), current.function());
return new ToolCall(id, type, function);
}
private ChatCompletionFunction merge(ChatCompletionFunction previous, ChatCompletionFunction current) {
if (previous == null) {
return current;
}
String name = (current.name() != null ? current.name() : previous.name());
StringBuilder arguments = new StringBuilder();
if (previous.arguments() != null) {
arguments.append(previous.arguments());
}
if (current.arguments() != null) {
arguments.append(current.arguments());
}
return new ChatCompletionFunction(name, arguments.toString());
}
/**
* @param chatCompletion the ChatCompletionChunk to check
* @return true if the ChatCompletionChunk is a streaming tool function call.
*/
public boolean isStreamingToolFunctionCall(ChatCompletionChunk chatCompletion) {
if (chatCompletion == null || CollectionUtils.isEmpty(chatCompletion.choices())) {
return false;
}
var choice = chatCompletion.choices().get(0);
if (choice == null || choice.delta() == null) {
return false;
}
return !CollectionUtils.isEmpty(choice.delta().toolCalls());
}
/**
* @param chatCompletion the ChatCompletionChunk to check
* @return true if the ChatCompletionChunk is a streaming tool function call and it is
* the last one.
*/
public boolean isStreamingToolFunctionCallFinish(ChatCompletionChunk chatCompletion) {
if (chatCompletion == null || CollectionUtils.isEmpty(chatCompletion.choices())) {
return false;
}
var choice = chatCompletion.choices().get(0);
if (choice == null || choice.delta() == null) {
return false;
}
return choice.finishReason() == ChatCompletionFinishReason.TOOL_CALLS;
}
}

View File

@@ -0,0 +1,126 @@
/*
* 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.deepseek.api;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Objects;
/**
* An object specifying the format that the model must output. Setting to { "type":
* "json_object" } enables JSON Output, which guarantees the message the model generates
* is valid JSON.
* <p>
* Important: When using JSON Output, you must also instruct the model to produce JSON
* yourself via a system or user message. Without this, the model may generate an unending
* stream of whitespace until the generation reaches the token limit, resulting in a
* long-running and seemingly "stuck" request. Also note that the message content may be
* partially cut off if finish_reason="length", which indicates the generation exceeded
* max_tokens or the conversation exceeded the max context length.
* <p>
* References:
* <a href= "https://api-docs.deepseek.com/api/create-chat-completion">DeepSeek API -
* Create Chat Completion</a>
*
* @author Geng Rong
*/
@JsonInclude(Include.NON_NULL)
public class ResponseFormat {
/**
* Type Must be one of 'text', 'json_object'.
*/
@JsonProperty("type")
private Type type;
public Type getType() {
return this.type;
}
public void setType(Type type) {
this.type = type;
}
private ResponseFormat(Type type) {
this.type = type;
}
public static Builder builder() {
return new Builder();
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
ResponseFormat that = (ResponseFormat) o;
return this.type == that.type;
}
@Override
public int hashCode() {
return Objects.hash(this.type);
}
@Override
public String toString() {
return "ResponseFormat{" + "type=" + this.type + '}';
}
public static final class Builder {
private Type type;
private Builder() {
}
public Builder type(Type type) {
this.type = type;
return this;
}
public ResponseFormat build() {
return new ResponseFormat(this.type);
}
}
public enum Type {
/**
* Generates a text response. (default)
*/
@JsonProperty("text")
TEXT,
/**
* Enables JSON mode, which guarantees the message the model generates is valid
* JSON.
*/
@JsonProperty("json_object")
JSON_OBJECT,
}
}

View File

@@ -0,0 +1,37 @@
/*
* 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.deepseek.api.common;
import org.springframework.ai.observation.conventions.AiProvider;
/**
* @author Geng Rong
*/
public class DeepSeekConstants {
public static final String DEFAULT_BASE_URL = "https://api.deepseek.com";
public static final String DEFAULT_COMPLETIONS_PATH = "/chat/completions";
public static final String DEFAULT_BETA_PATH = "/beta";
public static final String PROVIDER_NAME = AiProvider.DEEPSEEK.value();
private DeepSeekConstants() {
}
}

View File

@@ -0,0 +1,2 @@
org.springframework.aot.hint.RuntimeHintsRegistrar=\
org.springframework.ai.deepseek.aot.DeepSeekRuntimeHints

View File

@@ -0,0 +1,57 @@
/*
* 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.deepseek;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.deepseek.api.DeepSeekApi;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Geng Rong
*/
public class DeepSeekChatCompletionRequestTests {
@Test
public void createRequestWithChatOptions() {
var client = DeepSeekChatModel.builder()
.deepSeekApi(DeepSeekApi.builder().apiKey("TEST").build())
.defaultOptions(DeepSeekChatOptions.builder().model("DEFAULT_MODEL").temperature(66.6).build())
.build();
var prompt = client.buildRequestPrompt(new Prompt("Test message content"));
var request = client.createRequest(prompt, false);
assertThat(request.messages()).hasSize(1);
assertThat(request.stream()).isFalse();
assertThat(request.model()).isEqualTo("DEFAULT_MODEL");
assertThat(request.temperature()).isEqualTo(66.6D);
request = client.createRequest(new Prompt("Test message content",
DeepSeekChatOptions.builder().model("PROMPT_MODEL").temperature(99.9D).build()), true);
assertThat(request.messages()).hasSize(1);
assertThat(request.stream()).isTrue();
assertThat(request.model()).isEqualTo("PROMPT_MODEL");
assertThat(request.temperature()).isEqualTo(99.9D);
}
}

View File

@@ -0,0 +1,146 @@
/*
* 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.deepseek;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.deepseek.api.DeepSeekApi;
import org.springframework.ai.deepseek.api.DeepSeekApi.*;
import org.springframework.ai.deepseek.api.DeepSeekApi.ChatCompletionMessage.Role;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.ai.retry.TransientAiException;
import org.springframework.http.ResponseEntity;
import org.springframework.retry.RetryCallback;
import org.springframework.retry.RetryContext;
import org.springframework.retry.RetryListener;
import org.springframework.retry.support.RetryTemplate;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.isA;
import static org.mockito.BDDMockito.given;
/**
* @author Geng Rong
*/
@SuppressWarnings("unchecked")
@ExtendWith(MockitoExtension.class)
public class DeepSeekRetryTests {
private TestRetryListener retryListener;
private @Mock DeepSeekApi deepSeekApi;
private DeepSeekChatModel chatModel;
@BeforeEach
public void beforeEach() {
RetryTemplate retryTemplate = RetryUtils.SHORT_RETRY_TEMPLATE;
this.retryListener = new TestRetryListener();
retryTemplate.registerListener(this.retryListener);
this.chatModel = DeepSeekChatModel.builder()
.deepSeekApi(this.deepSeekApi)
.defaultOptions(DeepSeekChatOptions.builder().build())
.retryTemplate(retryTemplate)
.build();
;
}
@Test
public void deepSeekChatTransientError() {
var choice = new ChatCompletion.Choice(ChatCompletionFinishReason.STOP, 0,
new ChatCompletionMessage("Response", Role.ASSISTANT), null);
ChatCompletion expectedChatCompletion = new ChatCompletion("id", List.of(choice), 789L, "model", null,
"chat.completion", new DeepSeekApi.Usage(10, 10, 10));
given(this.deepSeekApi.chatCompletionEntity(isA(ChatCompletionRequest.class)))
.willThrow(new TransientAiException("Transient Error 1"))
.willThrow(new TransientAiException("Transient Error 2"))
.willReturn(ResponseEntity.of(Optional.of(expectedChatCompletion)));
var result = this.chatModel.call(new Prompt("text"));
assertThat(result).isNotNull();
assertThat(result.getResult().getOutput().getText()).isSameAs("Response");
assertThat(this.retryListener.onSuccessRetryCount).isEqualTo(2);
assertThat(this.retryListener.onErrorRetryCount).isEqualTo(2);
}
@Test
public void deepSeekChatNonTransientError() {
given(this.deepSeekApi.chatCompletionEntity(isA(ChatCompletionRequest.class)))
.willThrow(new RuntimeException("Non Transient Error"));
assertThrows(RuntimeException.class, () -> this.chatModel.call(new Prompt("text")));
}
@Test
public void deepSeekChatStreamTransientError() {
var choice = new ChatCompletion.Choice(ChatCompletionFinishReason.STOP, 0,
new ChatCompletionMessage("Response", Role.ASSISTANT), null);
ChatCompletion expectedChatCompletion = new ChatCompletion("id", List.of(choice), 666L, "model", null,
"chat.completion", new DeepSeekApi.Usage(10, 10, 10));
given(this.deepSeekApi.chatCompletionEntity(isA(ChatCompletionRequest.class)))
.willThrow(new TransientAiException("Transient Error 1"))
.willThrow(new TransientAiException("Transient Error 2"))
.willReturn(ResponseEntity.of(Optional.of(expectedChatCompletion)));
var result = this.chatModel.call(new Prompt("text"));
assertThat(result).isNotNull();
assertThat(result.getResult().getOutput().getText()).isSameAs("Response");
assertThat(this.retryListener.onSuccessRetryCount).isEqualTo(2);
assertThat(this.retryListener.onErrorRetryCount).isEqualTo(2);
}
@Test
public void deepSeekChatStreamNonTransientError() {
given(this.deepSeekApi.chatCompletionStream(isA(ChatCompletionRequest.class)))
.willThrow(new RuntimeException("Non Transient Error"));
assertThrows(RuntimeException.class, () -> this.chatModel.stream(new Prompt("text")).collectList().block());
}
private static class TestRetryListener implements RetryListener {
int onErrorRetryCount = 0;
int onSuccessRetryCount = 0;
@Override
public <T, E extends Throwable> void onSuccess(RetryContext context, RetryCallback<T, E> callback, T result) {
this.onSuccessRetryCount = context.getRetryCount();
}
@Override
public <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback,
Throwable throwable) {
this.onErrorRetryCount = context.getRetryCount();
}
}
}

View File

@@ -0,0 +1,48 @@
/*
* 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.deepseek;
import org.springframework.ai.deepseek.api.DeepSeekApi;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.util.StringUtils;
/**
* @author Geng Rong
*/
@SpringBootConfiguration
public class DeepSeekTestConfiguration {
@Bean
public DeepSeekApi deepSeekApi() {
return DeepSeekApi.builder().apiKey(getApiKey()).build();
}
private String getApiKey() {
String apiKey = System.getenv("DEEPSEEK_API_KEY");
if (!StringUtils.hasText(apiKey)) {
throw new IllegalArgumentException(
"You must provide an API key. Put it in an environment variable under the name DEEPSEEK_API_KEY");
}
return apiKey;
}
@Bean
public DeepSeekChatModel deepSeekChatModel(DeepSeekApi api) {
return DeepSeekChatModel.builder().deepSeekApi(api).build();
}
}

View File

@@ -0,0 +1,46 @@
/*
* 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.deepseek.aot;
import org.junit.jupiter.api.Test;
import org.springframework.ai.deepseek.api.DeepSeekApi;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.TypeReference;
import java.util.Set;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.springframework.ai.aot.AiRuntimeHints.findJsonAnnotatedClassesInPackage;
import static org.springframework.aot.hint.predicate.RuntimeHintsPredicates.reflection;
/**
* @author Geng Rong
*/
class DeepSeekRuntimeHintsTests {
@Test
void registerHints() {
RuntimeHints runtimeHints = new RuntimeHints();
DeepSeekRuntimeHints deepSeekRuntimeHints = new DeepSeekRuntimeHints();
deepSeekRuntimeHints.registerHints(runtimeHints, null);
Set<TypeReference> jsonAnnotatedClasses = findJsonAnnotatedClassesInPackage(DeepSeekApi.class);
for (TypeReference jsonAnnotatedClass : jsonAnnotatedClasses) {
assertThat(runtimeHints).matches(reflection().onType(jsonAnnotatedClass));
}
}
}

View File

@@ -0,0 +1,57 @@
/*
* 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.deepseek.api;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.deepseek.api.DeepSeekApi.*;
import org.springframework.ai.deepseek.api.DeepSeekApi.ChatCompletionMessage.Role;
import org.springframework.http.ResponseEntity;
import reactor.core.publisher.Flux;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Geng Rong
*/
@EnabledIfEnvironmentVariable(named = "DEEPSEEK_API_KEY", matches = ".+")
public class DeepSeekApiIT {
DeepSeekApi deepSeekApi = DeepSeekApi.builder().apiKey(System.getenv("DEEPSEEK_API_KEY")).build();
@Test
void chatCompletionEntity() {
ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage("Hello world", Role.USER);
ResponseEntity<ChatCompletion> response = deepSeekApi.chatCompletionEntity(
new ChatCompletionRequest(List.of(chatCompletionMessage), ChatModel.DEEPSEEK_CHAT.value, 1D, false));
assertThat(response).isNotNull();
assertThat(response.getBody()).isNotNull();
}
@Test
void chatCompletionStream() {
ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage("Hello world", Role.USER);
Flux<ChatCompletionChunk> response = deepSeekApi.chatCompletionStream(
new ChatCompletionRequest(List.of(chatCompletionMessage), ChatModel.DEEPSEEK_CHAT.value, 1D, true));
assertThat(response).isNotNull();
assertThat(response.collectList().block()).isNotNull();
}
}

View File

@@ -0,0 +1,95 @@
/*
* 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.deepseek.api;
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;
import java.util.function.Function;
/**
* @author Geng Rong
*/
public class MockWeatherService implements Function<MockWeatherService.Request, MockWeatherService.Response> {
@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, 15, 20, 2, 53, 45, request.unit);
}
/**
* Temperature units.
*/
public enum Unit {
/**
* Celsius.
*/
C("metric"),
/**
* Fahrenheit.
*/
F("imperial");
/**
* Human readable unit name.
*/
public final String unitName;
Unit(String text) {
this.unitName = text;
}
}
/**
* 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("lat") @JsonPropertyDescription("The city latitude") double lat,
@JsonProperty("lon") @JsonPropertyDescription("The city longitude") double lon,
@JsonProperty(required = true, value = "unit") @JsonPropertyDescription("Temperature unit") Unit unit) {
}
/**
* Weather Function response.
*/
public record Response(double temp, double feels_like, double temp_min, double temp_max, int pressure, int humidity,
Unit unit) {
}
}

View File

@@ -0,0 +1,53 @@
/*
* 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.deepseek.chat;
import java.util.List;
/**
* @author Geng Rong
*/
public class ActorsFilms {
private String actor;
private List<String> movies;
public ActorsFilms() {
}
public String getActor() {
return actor;
}
public void setActor(String actor) {
this.actor = actor;
}
public List<String> getMovies() {
return movies;
}
public void setMovies(List<String> movies) {
this.movies = movies;
}
@Override
public String toString() {
return "ActorsFilms{" + "actor='" + actor + '\'' + ", movies=" + movies + '}';
}
}

View File

@@ -0,0 +1,186 @@
/*
* Copyright 2023-2025 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.deepseek.chat;
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.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.deepseek.DeepSeekChatOptions;
import org.springframework.ai.deepseek.DeepSeekTestConfiguration;
import org.springframework.ai.deepseek.api.DeepSeekApi;
import org.springframework.ai.deepseek.api.MockWeatherService;
import org.springframework.ai.tool.function.FunctionToolCallback;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Geng Rong
*/
@SpringBootTest(classes = DeepSeekTestConfiguration.class)
// @Disabled("the deepseek-chat model's Function Calling capability is unstable see:
// https://api-docs.deepseek.com/guides/function_calling")
@EnabledIfEnvironmentVariable(named = "DEEPSEEK_API_KEY", matches = ".+")
class DeepSeekChatModelFunctionCallingIT {
private static final Logger logger = LoggerFactory.getLogger(DeepSeekChatModelFunctionCallingIT.class);
@Autowired
ChatModel chatModel;
private static final DeepSeekApi.FunctionTool FUNCTION_TOOL = new DeepSeekApi.FunctionTool(
DeepSeekApi.FunctionTool.Type.FUNCTION, new DeepSeekApi.FunctionTool.Function(
"Get the weather in location. Return temperature in 30°F or 30°C format.", "getCurrentWeather", """
{
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state e.g. San Francisco, CA"
},
"lat": {
"type": "number",
"description": "The city latitude"
},
"lon": {
"type": "number",
"description": "The city longitude"
},
"unit": {
"type": "string",
"enum": ["C", "F"]
}
},
"required": ["location", "lat", "lon", "unit"]
}
"""));
@Test
void functionCallTest() {
UserMessage userMessage = new UserMessage(
"What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius.");
List<Message> messages = new ArrayList<>(List.of(userMessage));
var promptOptions = DeepSeekChatOptions.builder()
.model(DeepSeekApi.ChatModel.DEEPSEEK_CHAT.getValue())
.toolCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService())
.description("Get the weather in location")
.inputType(MockWeatherService.Request.class)
.build()))
.build();
ChatResponse response = this.chatModel.call(new Prompt(messages, promptOptions));
logger.info("Response: {}", response);
assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15");
}
@Test
void streamFunctionCallTest() {
UserMessage userMessage = new UserMessage(
"What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius.");
List<Message> messages = new ArrayList<>(List.of(userMessage));
var promptOptions = DeepSeekChatOptions.builder()
.toolCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService())
.description("Get the weather in location")
.inputType(MockWeatherService.Request.class)
.build()))
.build();
Flux<ChatResponse> response = this.chatModel.stream(new Prompt(messages, promptOptions));
String content = response.collectList()
.block()
.stream()
.map(ChatResponse::getResults)
.flatMap(List::stream)
.map(Generation::getOutput)
.map(AssistantMessage::getText)
.filter(Objects::nonNull)
.collect(Collectors.joining());
logger.info("Response: {}", content);
assertThat(content).contains("30", "10", "15");
}
@Test
public void toolFunctionCallWithUsage() {
var promptOptions = DeepSeekChatOptions.builder()
.model(DeepSeekApi.ChatModel.DEEPSEEK_CHAT.getValue())
.tools(Arrays.asList(FUNCTION_TOOL))
.toolCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService())
.description("Get the weather in location")
.inputType(MockWeatherService.Request.class)
.build()))
.build();
Prompt prompt = new Prompt("What's the weather like in San Francisco? Return the temperature in Celsius.",
promptOptions);
ChatResponse chatResponse = this.chatModel.call(prompt);
assertThat(chatResponse).isNotNull();
assertThat(chatResponse.getResult().getOutput());
assertThat(chatResponse.getResult().getOutput().getText()).contains("San Francisco");
assertThat(chatResponse.getResult().getOutput().getText()).contains("30");
// 这个 total token 是第一次 chat 以及 tool call 之后的两次请求 token 总和
// the total token is first chat and tool call request
assertThat(chatResponse.getMetadata().getUsage().getTotalTokens()).isLessThan(700).isGreaterThan(280);
}
@Test
public void testStreamFunctionCallUsage() {
var promptOptions = DeepSeekChatOptions.builder()
.model(DeepSeekApi.ChatModel.DEEPSEEK_CHAT.getValue())
.tools(Arrays.asList(FUNCTION_TOOL))
.toolCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService())
.description("Get the weather in location")
.inputType(MockWeatherService.Request.class)
.build()))
.build();
Prompt prompt = new Prompt("What's the weather like in San Francisco? Return the temperature in Celsius.",
promptOptions);
ChatResponse chatResponse = this.chatModel.stream(prompt).blockLast();
assertThat(chatResponse).isNotNull();
assertThat(chatResponse.getMetadata()).isNotNull();
assertThat(chatResponse.getMetadata().getUsage()).isNotNull();
assertThat(chatResponse.getMetadata().getUsage().getTotalTokens()).isLessThan(700).isGreaterThan(280);
}
}

View File

@@ -0,0 +1,278 @@
/*
* 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.deepseek.chat;
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.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.model.StreamingChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.chat.prompt.SystemPromptTemplate;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.converter.ListOutputConverter;
import org.springframework.ai.converter.MapOutputConverter;
import org.springframework.ai.deepseek.DeepSeekChatOptions;
import org.springframework.ai.deepseek.DeepSeekTestConfiguration;
import org.springframework.ai.deepseek.DeepSeekAssistantMessage;
import org.springframework.ai.deepseek.api.DeepSeekApi;
import org.springframework.ai.deepseek.api.MockWeatherService;
import org.springframework.ai.tool.function.FunctionToolCallback;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.core.io.Resource;
import java.util.*;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Geng Rong
*/
@SpringBootTest(classes = DeepSeekTestConfiguration.class)
@EnabledIfEnvironmentVariable(named = "DEEPSEEK_API_KEY", matches = ".+")
class DeepSeekChatModelIT {
@Autowired
protected ChatModel chatModel;
@Autowired
protected StreamingChatModel streamingChatModel;
private static final Logger logger = LoggerFactory.getLogger(DeepSeekChatModelIT.class);
@Value("classpath:/prompts/system-message.st")
private Resource systemResource;
@Test
void roleTest() {
UserMessage userMessage = new UserMessage(
"Tell me about 3 famous pirates from the Golden Age of Piracy and what they did.");
SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(systemResource);
Message systemMessage = systemPromptTemplate.createMessage(Map.of("name", "Bob", "voice", "pirate"));
Prompt prompt = new Prompt(List.of(systemMessage, userMessage));
ChatResponse response = chatModel.call(prompt);
assertThat(response.getResults()).hasSize(1);
assertThat(response.getResults().get(0).getOutput().getText()).contains("Blackbeard");
// needs fine tuning... evaluateQuestionAndAnswer(request, response, false);
}
@Test
void listOutputConverter() {
DefaultConversionService conversionService = new DefaultConversionService();
ListOutputConverter outputConverter = new ListOutputConverter(conversionService);
String format = outputConverter.getFormat();
String template = """
List five {subject}
{format}
""";
PromptTemplate promptTemplate = PromptTemplate.builder()
.template(template)
.variables(Map.of("subject", "ice cream flavors", "format", format))
.build();
Prompt prompt = new Prompt(promptTemplate.createMessage());
Generation generation = this.chatModel.call(prompt).getResult();
List<String> list = outputConverter.convert(generation.getOutput().getText());
assertThat(list).hasSize(5);
}
@Test
void mapOutputConverter() {
MapOutputConverter outputConverter = new MapOutputConverter();
String format = outputConverter.getFormat();
String template = """
Please provide the JSON response without any code block markers such as ```json```.
Provide me a List of {subject}
{format}
""";
PromptTemplate promptTemplate = PromptTemplate.builder()
.template(template)
.variables(Map.of("subject", "an array of numbers from 1 to 9 under they key name 'numbers'", "format",
format))
.build();
Prompt prompt = new Prompt(promptTemplate.createMessage());
Generation generation = chatModel.call(prompt).getResult();
Map<String, Object> result = outputConverter.convert(generation.getOutput().getText());
assertThat(result.get("numbers")).isEqualTo(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9));
}
@Test
void beanOutputConverter() {
BeanOutputConverter<ActorsFilms> outputConverter = new BeanOutputConverter<>(ActorsFilms.class);
String format = outputConverter.getFormat();
String template = """
Generate the filmography for a random actor.
Please provide the JSON response without any code block markers such as ```json```.
{format}
""";
PromptTemplate promptTemplate = PromptTemplate.builder()
.template(template)
.variables(Map.of("format", format))
.build();
Prompt prompt = new Prompt(promptTemplate.createMessage());
Generation generation = chatModel.call(prompt).getResult();
ActorsFilms actorsFilms = outputConverter.convert(generation.getOutput().getText());
}
record ActorsFilmsRecord(String actor, List<String> movies) {
}
@Test
void beanOutputConverterRecords() {
BeanOutputConverter<ActorsFilmsRecord> outputConverter = new BeanOutputConverter<>(ActorsFilmsRecord.class);
String format = outputConverter.getFormat();
String template = """
Generate the filmography of 5 movies for Tom Hanks.
Please provide the JSON response without any code block markers such as ```json```.
{format}
""";
PromptTemplate promptTemplate = PromptTemplate.builder()
.template(template)
.variables(Map.of("format", format))
.build();
Prompt prompt = new Prompt(promptTemplate.createMessage());
Generation generation = chatModel.call(prompt).getResult();
ActorsFilmsRecord actorsFilms = outputConverter.convert(generation.getOutput().getText());
logger.info("" + actorsFilms);
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
assertThat(actorsFilms.movies()).hasSize(5);
}
@Test
void beanStreamOutputConverterRecords() {
BeanOutputConverter<ActorsFilmsRecord> outputConverter = new BeanOutputConverter<>(ActorsFilmsRecord.class);
String format = outputConverter.getFormat();
String template = """
Generate the filmography of 5 movies for Tom Hanks.
Please provide the JSON response without any code block markers such as ```json```.
{format}
""";
PromptTemplate promptTemplate = PromptTemplate.builder()
.template(template)
.variables(Map.of("format", format))
.build();
Prompt prompt = new Prompt(promptTemplate.createMessage());
String generationTextFromStream = streamingChatModel.stream(prompt)
.collectList()
.block()
.stream()
.map(ChatResponse::getResults)
.flatMap(List::stream)
.map(Generation::getOutput)
.map(m -> m.getText() != null ? m.getText() : "")
.collect(Collectors.joining());
ActorsFilmsRecord actorsFilms = outputConverter.convert(generationTextFromStream);
logger.info("" + actorsFilms);
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
assertThat(actorsFilms.movies()).hasSize(5);
}
@Test
void prefixCompletionTest() {
String userMessageContent = """
Please return this yaml data to json.
data:
```yaml
code: 200
result:
total: 1
data:
- 1
- 2
- 3
```
""";
UserMessage userMessage = new UserMessage(userMessageContent);
Message assistantMessage = new DeepSeekAssistantMessage("{\"code\":200,\"result\":{\"total\":1,\"data\":[1");
Prompt prompt = new Prompt(List.of(userMessage, assistantMessage));
ChatResponse response = chatModel.call(prompt);
assertThat(response.getResult().getOutput().getText().equals(",2,3]}}"));
}
/**
* For deepseek-reasoner model only. The reasoning contents of the assistant message,
* before the final answer.
*/
@Test
void reasonerModelTest() {
var promptOptions = DeepSeekChatOptions.builder()
.model(DeepSeekApi.ChatModel.DEEPSEEK_REASONER.getValue())
.build();
Prompt prompt = new Prompt("9.11 and 9.8, which is greater?", promptOptions);
ChatResponse response = chatModel.call(prompt);
DeepSeekAssistantMessage deepSeekAssistantMessage = (DeepSeekAssistantMessage) response.getResult().getOutput();
assertThat(deepSeekAssistantMessage.getReasoningContent()).isNotEmpty();
assertThat(deepSeekAssistantMessage.getText()).isNotEmpty();
}
/**
* the deepseek-reasoner model Multi-round Conversation.
*/
@Test
void reasonerModelMultiRoundTest() {
List<Message> messages = new ArrayList<>();
messages.add(new UserMessage("9.11 and 9.8, which is greater?"));
var promptOptions = DeepSeekChatOptions.builder()
.model(DeepSeekApi.ChatModel.DEEPSEEK_REASONER.getValue())
.build();
Prompt prompt = new Prompt(messages, promptOptions);
ChatResponse response = chatModel.call(prompt);
DeepSeekAssistantMessage deepSeekAssistantMessage = (DeepSeekAssistantMessage) response.getResult().getOutput();
assertThat(deepSeekAssistantMessage.getReasoningContent()).isNotEmpty();
assertThat(deepSeekAssistantMessage.getText()).isNotEmpty();
messages.add(new AssistantMessage(Objects.requireNonNull(deepSeekAssistantMessage.getText())));
messages.add(new UserMessage("How many Rs are there in the word 'strawberry'?"));
Prompt prompt2 = new Prompt(messages, promptOptions);
ChatResponse response2 = chatModel.call(prompt2);
DeepSeekAssistantMessage deepSeekAssistantMessage2 = (DeepSeekAssistantMessage) response2.getResult()
.getOutput();
assertThat(deepSeekAssistantMessage2.getReasoningContent()).isNotEmpty();
assertThat(deepSeekAssistantMessage2.getText()).isNotEmpty();
}
}

View File

@@ -0,0 +1,179 @@
/*
* 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.deepseek.chat;
import io.micrometer.observation.tck.TestObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistryAssert;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.deepseek.DeepSeekChatModel;
import org.springframework.ai.deepseek.DeepSeekChatOptions;
import org.springframework.ai.deepseek.api.DeepSeekApi;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.retry.support.RetryTemplate;
import reactor.core.publisher.Flux;
import java.util.List;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.HighCardinalityKeyNames;
import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.LowCardinalityKeyNames;
/**
* Integration tests for observation instrumentation in {@link DeepSeekChatModel}.
*
* @author Geng Rong
*/
@SpringBootTest(classes = DeepSeekChatModelObservationIT.Config.class)
@EnabledIfEnvironmentVariable(named = "DEEPSEEK_API_KEY", matches = ".+")
public class DeepSeekChatModelObservationIT {
@Autowired
TestObservationRegistry observationRegistry;
@Autowired
DeepSeekChatModel chatModel;
@BeforeEach
void beforeEach() {
this.observationRegistry.clear();
}
@Test
void observationForChatOperation() {
var options = DeepSeekChatOptions.builder()
.model(DeepSeekApi.ChatModel.DEEPSEEK_CHAT.getValue())
.frequencyPenalty(0.0)
.maxTokens(2048)
.presencePenalty(0.0)
.stop(List.of("this-is-the-end"))
.temperature(0.7)
.topP(1.0)
.build();
Prompt prompt = new Prompt("Why does a raven look like a desk?", options);
ChatResponse chatResponse = this.chatModel.call(prompt);
assertThat(chatResponse.getResult().getOutput().getText()).isNotEmpty();
ChatResponseMetadata responseMetadata = chatResponse.getMetadata();
assertThat(responseMetadata).isNotNull();
validate(responseMetadata);
}
@Test
void observationForStreamingChatOperation() {
var options = DeepSeekChatOptions.builder()
.model(DeepSeekApi.ChatModel.DEEPSEEK_CHAT.getValue())
.frequencyPenalty(0.0)
.maxTokens(2048)
.presencePenalty(0.0)
.stop(List.of("this-is-the-end"))
.temperature(0.7)
.topP(1.0)
.build();
Prompt prompt = new Prompt("Why does a raven look like a desk?", options);
Flux<ChatResponse> chatResponseFlux = this.chatModel.stream(prompt);
List<ChatResponse> responses = chatResponseFlux.collectList().block();
assertThat(responses).isNotEmpty();
assertThat(responses).hasSizeGreaterThan(10);
String aggregatedResponse = responses.subList(0, responses.size() - 1)
.stream()
.map(r -> r.getResult().getOutput().getText())
.collect(Collectors.joining());
assertThat(aggregatedResponse).isNotEmpty();
ChatResponse lastChatResponse = responses.get(responses.size() - 1);
ChatResponseMetadata responseMetadata = lastChatResponse.getMetadata();
assertThat(responseMetadata).isNotNull();
validate(responseMetadata);
}
private void validate(ChatResponseMetadata responseMetadata) {
TestObservationRegistryAssert.assertThat(this.observationRegistry)
.doesNotHaveAnyRemainingCurrentObservation()
.hasObservationWithNameEqualTo(DefaultChatModelObservationConvention.DEFAULT_NAME)
.that()
.hasContextualNameEqualTo("chat " + DeepSeekApi.ChatModel.DEEPSEEK_CHAT.getValue())
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(),
AiOperationType.CHAT.value())
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_PROVIDER.asString(), AiProvider.DEEPSEEK.value())
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.REQUEST_MODEL.asString(),
DeepSeekApi.ChatModel.DEEPSEEK_CHAT.getValue())
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), responseMetadata.getModel())
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_FREQUENCY_PENALTY.asString(), "0.0")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_MAX_TOKENS.asString(), "2048")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_PRESENCE_PENALTY.asString(), "0.0")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_STOP_SEQUENCES.asString(),
"[\"this-is-the-end\"]")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_TEMPERATURE.asString(), "0.7")
.doesNotHaveHighCardinalityKeyValueWithKey(HighCardinalityKeyNames.REQUEST_TOP_K.asString())
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_TOP_P.asString(), "1.0")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.RESPONSE_ID.asString(), responseMetadata.getId())
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.RESPONSE_FINISH_REASONS.asString(), "[\"STOP\"]")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_INPUT_TOKENS.asString(),
String.valueOf(responseMetadata.getUsage().getPromptTokens()))
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_OUTPUT_TOKENS.asString(),
String.valueOf(responseMetadata.getUsage().getCompletionTokens()))
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_TOTAL_TOKENS.asString(),
String.valueOf(responseMetadata.getUsage().getTotalTokens()))
.hasBeenStarted()
.hasBeenStopped();
}
@SpringBootConfiguration
static class Config {
@Bean
public TestObservationRegistry observationRegistry() {
return TestObservationRegistry.create();
}
@Bean
public DeepSeekApi deepSeekApi() {
return DeepSeekApi.builder().apiKey(System.getenv("DEEPSEEK_API_KEY")).build();
}
@Bean
public DeepSeekChatModel deepSeekChatModel(DeepSeekApi deepSeekApi,
TestObservationRegistry observationRegistry) {
return new DeepSeekChatModel(deepSeekApi, DeepSeekChatOptions.builder().build(),
ToolCallingManager.builder().build(), RetryTemplate.defaultInstance(), observationRegistry);
}
}
}

View File

@@ -0,0 +1,4 @@
"You are a helpful AI assistant. Your name is {name}.
You are an AI assistant that helps people find information.
Your name is {name}
You should reply to the user's request with your name and also in the style of a {voice}.

View File

@@ -172,6 +172,7 @@
<module>models/spring-ai-vertex-ai-embedding</module>
<module>models/spring-ai-vertex-ai-gemini</module>
<module>models/spring-ai-zhipuai</module>
<module>models/spring-ai-deepseek</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-model-anthropic</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-model-azure-openai</module>

View File

@@ -78,6 +78,11 @@ public enum AiProvider {
*/
ZHIPUAI("zhipuai"),
/**
* AI system provided by DeepSeek.
*/
DEEPSEEK("deepseek"),
/**
* AI system provided by Spring AI.
*/