diff --git a/models/spring-ai-deepseek/README.md b/models/spring-ai-deepseek/README.md new file mode 100644 index 000000000..2a0845251 --- /dev/null +++ b/models/spring-ai-deepseek/README.md @@ -0,0 +1 @@ +[DeepSeek Chat Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/chat/deepseek-chat.html) \ No newline at end of file diff --git a/models/spring-ai-deepseek/pom.xml b/models/spring-ai-deepseek/pom.xml new file mode 100644 index 000000000..c84a19fcd --- /dev/null +++ b/models/spring-ai-deepseek/pom.xml @@ -0,0 +1,64 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-deepseek + jar + Spring AI DeepSeek + DeepSeek support + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + org.springframework.ai + spring-ai-client-chat + ${project.parent.version} + + + + org.springframework.ai + spring-ai-retry + ${project.parent.version} + + + + + org.springframework + spring-context-support + + + + org.slf4j + slf4j-api + + + + + org.springframework.ai + spring-ai-test + ${project.version} + test + + + + io.micrometer + micrometer-observation-test + test + + + + + diff --git a/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/DeepSeekAssistantMessage.java b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/DeepSeekAssistantMessage.java new file mode 100644 index 000000000..e7ddcb6b8 --- /dev/null +++ b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/DeepSeekAssistantMessage.java @@ -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 properties) { + super(content, properties); + } + + public DeepSeekAssistantMessage(String content, Map properties, List toolCalls) { + super(content, properties, toolCalls); + } + + public DeepSeekAssistantMessage(String content, String reasoningContent, Map properties, + List toolCalls) { + this(content, reasoningContent, properties, toolCalls, List.of()); + } + + public DeepSeekAssistantMessage(String content, String reasoningContent, Map properties, + List toolCalls, List 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 + "]"; + } + +} diff --git a/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/DeepSeekChatModel.java b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/DeepSeekChatModel.java new file mode 100644 index 000000000..0d893bc2b --- /dev/null +++ b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/DeepSeekChatModel.java @@ -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 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 choices = chatCompletion.choices(); + if (choices == null) { + logger.warn("No choices returned for prompt: {}", prompt); + return new ChatResponse(List.of()); + } + + List generations = choices.stream().map(choice -> { + // @formatter:off + Map 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 stream(Prompt prompt) { + Prompt requestPrompt = buildRequestPrompt(prompt); + return internalStream(requestPrompt, null); + } + + public Flux internalStream(Prompt prompt, ChatResponse previousChatResponse) { + return Flux.deferContextual(contextView -> { + ChatCompletionRequest request = createRequest(prompt, true); + + Flux 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 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 = completionChunks.map(this::chunkToChatCompletion) + .switchMap(chatCompletion -> Mono.just(chatCompletion).map(chatCompletion2 -> { + try { + String id = chatCompletion2.id(); + + List generations = chatCompletion2.choices().stream().map(choice -> { + if (choice.message().role() != null) { + roleMap.putIfAbsent(id, choice.message().role().name()); + } + + // @formatter:off + Map 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 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 metadata) { + List 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 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 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 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 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 getFunctionTools(List 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); + } + + } + +} diff --git a/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/DeepSeekChatOptions.java b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/DeepSeekChatOptions.java new file mode 100644 index 000000000..5664338a4 --- /dev/null +++ b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/DeepSeekChatOptions.java @@ -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. + * DeepSeek + * chat completion + * + * @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 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 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 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 toolNames = new HashSet<>(); + + @JsonIgnore + private Map 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 getStopSequences() { + return getStop(); + } + + @JsonIgnore + public void setStopSequences(List stopSequences) { + setStop(stopSequences); + } + + public List getStop() { + return this.stop; + } + + public void setStop(List 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 getTools() { + return this.tools; + } + + public void setTools(List tools) { + this.tools = tools; + } + + public Object getToolChoice() { + return this.toolChoice; + } + + public void setToolChoice(Object toolChoice) { + this.toolChoice = toolChoice; + } + + + @Override + @JsonIgnore + public List getToolCallbacks() { + return this.toolCallbacks; + } + + @Override + @JsonIgnore + public void setToolCallbacks(List toolCallbacks) { + Assert.notNull(toolCallbacks, "toolCallbacks cannot be null"); + Assert.noNullElements(toolCallbacks, "toolCallbacks cannot contain null elements"); + this.toolCallbacks = toolCallbacks; + } + + @Override + @JsonIgnore + public Set getToolNames() { + return this.toolNames; + } + + @Override + @JsonIgnore + public void setToolNames(Set 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 getToolContext() { + return this.toolContext; + } + + @Override + public void setToolContext(Map 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 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 tools) { + this.options.tools = tools; + return this; + } + + public Builder toolChoice(Object toolChoice) { + this.options.toolChoice = toolChoice; + return this; + } + + public Builder toolCallbacks(List 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 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 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(); + } + +} diff --git a/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/aot/DeepSeekRuntimeHints.java b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/aot/DeepSeekRuntimeHints.java new file mode 100644 index 000000000..22d9ce8b5 --- /dev/null +++ b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/aot/DeepSeekRuntimeHints.java @@ -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); + } + +} diff --git a/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/DeepSeekApi.java b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/DeepSeekApi.java new file mode 100644 index 000000000..711a3359c --- /dev/null +++ b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/DeepSeekApi.java @@ -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 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 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 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 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 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 chatCompletionStream(ChatCompletionRequest chatRequest, + MultiValueMap 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 -> Flux> + .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 window into a single + // Mono, + // Flux> -> Flux> + .concatMapIterable(window -> { + Mono 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> -> Flux + .flatMap(mono -> mono); + } + + /** + * DeepSeek Chat Completion + * Models + */ + 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 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 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 getParameters() { + return this.parameters; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setName(String name) { + this.name = name; + } + + public void setParameters(Map 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 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 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 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 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 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 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 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 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 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, + @JsonProperty("refusal") List 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 probBytes, + @JsonProperty("top_logprobs") List 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 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 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 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 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); + } + + } + +} diff --git a/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/DeepSeekStreamFunctionCallingHelper.java b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/DeepSeekStreamFunctionCallingHelper.java new file mode 100644 index 000000000..7fe854428 --- /dev/null +++ b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/DeepSeekStreamFunctionCallingHelper.java @@ -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 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 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; + } + +} diff --git a/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/ResponseFormat.java b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/ResponseFormat.java new file mode 100644 index 000000000..826675545 --- /dev/null +++ b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/ResponseFormat.java @@ -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. + *

+ * 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. + *

+ * References: + * DeepSeek API - + * Create Chat Completion + * + * @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, + + } + +} diff --git a/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/common/DeepSeekConstants.java b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/common/DeepSeekConstants.java new file mode 100644 index 000000000..904b8e9a9 --- /dev/null +++ b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/common/DeepSeekConstants.java @@ -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() { + + } + +} diff --git a/models/spring-ai-deepseek/src/main/resources/META-INF/spring/aot.factories b/models/spring-ai-deepseek/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 000000000..112c3a5ee --- /dev/null +++ b/models/spring-ai-deepseek/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ + org.springframework.ai.deepseek.aot.DeepSeekRuntimeHints \ No newline at end of file diff --git a/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/DeepSeekChatCompletionRequestTests.java b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/DeepSeekChatCompletionRequestTests.java new file mode 100644 index 000000000..c5fafb72e --- /dev/null +++ b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/DeepSeekChatCompletionRequestTests.java @@ -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); + } + +} diff --git a/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/DeepSeekRetryTests.java b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/DeepSeekRetryTests.java new file mode 100644 index 000000000..772f2fe10 --- /dev/null +++ b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/DeepSeekRetryTests.java @@ -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 void onSuccess(RetryContext context, RetryCallback callback, T result) { + this.onSuccessRetryCount = context.getRetryCount(); + } + + @Override + public void onError(RetryContext context, RetryCallback callback, + Throwable throwable) { + this.onErrorRetryCount = context.getRetryCount(); + } + + } + +} diff --git a/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/DeepSeekTestConfiguration.java b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/DeepSeekTestConfiguration.java new file mode 100644 index 000000000..6e6cbdc3e --- /dev/null +++ b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/DeepSeekTestConfiguration.java @@ -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(); + } + +} diff --git a/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/aot/DeepSeekRuntimeHintsTests.java b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/aot/DeepSeekRuntimeHintsTests.java new file mode 100644 index 000000000..089db1171 --- /dev/null +++ b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/aot/DeepSeekRuntimeHintsTests.java @@ -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 jsonAnnotatedClasses = findJsonAnnotatedClassesInPackage(DeepSeekApi.class); + for (TypeReference jsonAnnotatedClass : jsonAnnotatedClasses) { + assertThat(runtimeHints).matches(reflection().onType(jsonAnnotatedClass)); + } + } + +} diff --git a/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/api/DeepSeekApiIT.java b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/api/DeepSeekApiIT.java new file mode 100644 index 000000000..0e0262560 --- /dev/null +++ b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/api/DeepSeekApiIT.java @@ -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 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 response = deepSeekApi.chatCompletionStream( + new ChatCompletionRequest(List.of(chatCompletionMessage), ChatModel.DEEPSEEK_CHAT.value, 1D, true)); + + assertThat(response).isNotNull(); + assertThat(response.collectList().block()).isNotNull(); + } + +} diff --git a/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/api/MockWeatherService.java b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/api/MockWeatherService.java new file mode 100644 index 000000000..060c65947 --- /dev/null +++ b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/api/MockWeatherService.java @@ -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 { + + @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) { + + } + +} diff --git a/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/chat/ActorsFilms.java b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/chat/ActorsFilms.java new file mode 100644 index 000000000..53f529ef3 --- /dev/null +++ b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/chat/ActorsFilms.java @@ -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 movies; + + public ActorsFilms() { + } + + public String getActor() { + return actor; + } + + public void setActor(String actor) { + this.actor = actor; + } + + public List getMovies() { + return movies; + } + + public void setMovies(List movies) { + this.movies = movies; + } + + @Override + public String toString() { + return "ActorsFilms{" + "actor='" + actor + '\'' + ", movies=" + movies + '}'; + } + +} diff --git a/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/chat/DeepSeekChatModelFunctionCallingIT.java b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/chat/DeepSeekChatModelFunctionCallingIT.java new file mode 100644 index 000000000..32306d340 --- /dev/null +++ b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/chat/DeepSeekChatModelFunctionCallingIT.java @@ -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 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 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 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); + } + +} diff --git a/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/chat/DeepSeekChatModelIT.java b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/chat/DeepSeekChatModelIT.java new file mode 100644 index 000000000..1909ce808 --- /dev/null +++ b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/chat/DeepSeekChatModelIT.java @@ -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 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 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 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 movies) { + } + + @Test + void beanOutputConverterRecords() { + + BeanOutputConverter 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 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 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(); + } + +} diff --git a/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/chat/DeepSeekChatModelObservationIT.java b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/chat/DeepSeekChatModelObservationIT.java new file mode 100644 index 000000000..e95cc46b8 --- /dev/null +++ b/models/spring-ai-deepseek/src/test/java/org/springframework/ai/deepseek/chat/DeepSeekChatModelObservationIT.java @@ -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 chatResponseFlux = this.chatModel.stream(prompt); + + List 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); + } + + } + +} diff --git a/models/spring-ai-deepseek/src/test/resources/prompts/system-message.st b/models/spring-ai-deepseek/src/test/resources/prompts/system-message.st new file mode 100644 index 000000000..dc2cf2dcd --- /dev/null +++ b/models/spring-ai-deepseek/src/test/resources/prompts/system-message.st @@ -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}. \ No newline at end of file diff --git a/pom.xml b/pom.xml index aa0e00718..925864714 100644 --- a/pom.xml +++ b/pom.xml @@ -172,6 +172,7 @@ models/spring-ai-vertex-ai-embedding models/spring-ai-vertex-ai-gemini models/spring-ai-zhipuai + models/spring-ai-deepseek spring-ai-spring-boot-starters/spring-ai-starter-model-anthropic spring-ai-spring-boot-starters/spring-ai-starter-model-azure-openai diff --git a/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java b/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java index 680896b9c..52abf2adc 100644 --- a/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java +++ b/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java @@ -78,6 +78,11 @@ public enum AiProvider { */ ZHIPUAI("zhipuai"), + /** + * AI system provided by DeepSeek. + */ + DEEPSEEK("deepseek"), + /** * AI system provided by Spring AI. */