Add core deepseek classes
Signed-off-by: Mark Pollack <mark.pollack@broadcom.com>
This commit is contained in:
1
models/spring-ai-deepseek/README.md
Normal file
1
models/spring-ai-deepseek/README.md
Normal file
@@ -0,0 +1 @@
|
||||
[DeepSeek Chat Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/chat/deepseek-chat.html)
|
||||
64
models/spring-ai-deepseek/pom.xml
Normal file
64
models/spring-ai-deepseek/pom.xml
Normal 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>
|
||||
@@ -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 + "]";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.springframework.aot.hint.RuntimeHintsRegistrar=\
|
||||
org.springframework.ai.deepseek.aot.DeepSeekRuntimeHints
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 + '}';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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}.
|
||||
1
pom.xml
1
pom.xml
@@ -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>
|
||||
|
||||
@@ -78,6 +78,11 @@ public enum AiProvider {
|
||||
*/
|
||||
ZHIPUAI("zhipuai"),
|
||||
|
||||
/**
|
||||
* AI system provided by DeepSeek.
|
||||
*/
|
||||
DEEPSEEK("deepseek"),
|
||||
|
||||
/**
|
||||
* AI system provided by Spring AI.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user