toolContext) {
+ options.setToolContext(toolContext);
+ return this;
+ }
+
+ public HunYuanChatOptions build() {
+ return options;
+ }
+
+ }
+
+}
diff --git a/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/aot/HunYuanRuntimeHints.java b/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/aot/HunYuanRuntimeHints.java
new file mode 100644
index 00000000000..cbe5906ea2f
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/aot/HunYuanRuntimeHints.java
@@ -0,0 +1,44 @@
+/*
+ * 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.hunyuan.aot;
+
+import org.springframework.ai.hunyuan.api.HunYuanApi;
+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 HunYuanRuntimeHints class is responsible for registering runtime hints for HunYuan
+ * API classes.
+ *
+ * @author Guo Junyu
+ */
+public class HunYuanRuntimeHints implements RuntimeHintsRegistrar {
+
+ @Override
+ public void registerHints(@NonNull RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ var mcs = MemberCategory.values();
+ for (var tr : findJsonAnnotatedClassesInPackage(HunYuanApi.class)) {
+ hints.reflection().registerType(tr, mcs);
+ }
+ }
+
+}
diff --git a/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/api/HunYuanApi.java b/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/api/HunYuanApi.java
new file mode 100644
index 00000000000..575b59aa9b3
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/api/HunYuanApi.java
@@ -0,0 +1,885 @@
+/*
+ * 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.hunyuan.api;
+
+import java.util.*;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonInclude.Include;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.ai.hunyuan.api.auth.HunYuanAuthApi;
+import org.springframework.util.MultiValueMap;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import org.springframework.ai.model.ChatModelDescription;
+import org.springframework.ai.model.ModelOptionsUtils;
+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.web.client.ResponseErrorHandler;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.reactive.function.client.WebClient;
+
+/**
+ * Single-class, Java Client library for HunYuan platform. Provides implementation for the
+ * Chat Completion APIs.
+ *
+ * Implements Synchronous and Streaming chat completion.
+ *
+ *
+ * @author Guo Junyu
+ */
+public class HunYuanApi {
+
+ private static final Logger logger = LoggerFactory.getLogger(HunYuanApi.class);
+
+ public static final String DEFAULT_CHAT_MODEL = ChatModel.HUNYUAN_PRO.getValue();
+
+ private static final Predicate SSE_DONE_PREDICATE = "[DONE]"::equals;
+
+ private final RestClient restClient;
+
+ private final WebClient webClient;
+
+ private final HunYuanAuthApi hunyuanAuthApi;
+
+ private final HunYuanStreamFunctionCallingHelper chunkMerger = new HunYuanStreamFunctionCallingHelper();
+
+ /**
+ * Create a new client api with DEFAULT_BASE_URL
+ * @param secretId Hunyuan SecretId.
+ * @param secretKey Hunyuan SecretKey.
+ */
+ public HunYuanApi(String secretId, String secretKey) {
+ this(HunYuanConstants.DEFAULT_BASE_URL, secretId, secretKey);
+ }
+
+ /**
+ * Create a new client api.
+ * @param baseUrl api base URL.
+ * @param secretId Hunyuan SecretId.
+ * @param secretKey Hunyuan SecretKey.
+ */
+ public HunYuanApi(String baseUrl, String secretId, String secretKey) {
+ this(baseUrl, secretId, secretKey, RestClient.builder(), RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER);
+ }
+
+ /**
+ * Create a new client api.
+ * @param baseUrl api base URL.
+ * @param secretKey Hunyuan api Key.
+ * @param restClientBuilder RestClient builder.
+ * @param responseErrorHandler Response error handler.
+ */
+ public HunYuanApi(String baseUrl, String secretId, String secretKey, RestClient.Builder restClientBuilder,
+ ResponseErrorHandler responseErrorHandler) {
+
+ Consumer jsonContentHeaders = headers -> {
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ };
+ hunyuanAuthApi = new HunYuanAuthApi(secretId, secretKey);
+ this.restClient = restClientBuilder.baseUrl(baseUrl)
+ .defaultHeaders(jsonContentHeaders)
+ .defaultStatusHandler(responseErrorHandler)
+ .build();
+
+ this.webClient = WebClient.builder().baseUrl(baseUrl).defaultHeaders(jsonContentHeaders).build();
+ }
+
+ /**
+ * 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.");
+ String service = HunYuanConstants.DEFAULT_SERVICE;
+ String host = HunYuanConstants.DEFAULT_CHAT_HOST;
+ // String region = "ap-guangzhou";
+ String action = HunYuanConstants.DEFAULT_CHAT_ACTION;
+ MultiValueMap jsonContentHeaders = hunyuanAuthApi.getHttpHeadersConsumer(host, action, service,
+ chatRequest);
+ ResponseEntity retrieve = this.restClient.post().uri("/").headers(headers -> {
+ headers.addAll(jsonContentHeaders);
+ }).body(chatRequest).retrieve().toEntity(String.class);
+ // Compatible Return Position text/plain
+ logger.info("Response body: {}", retrieve.getBody());
+ ChatCompletionResponse chatCompletionResponse = ModelOptionsUtils.jsonToObject(retrieve.getBody(),
+ ChatCompletionResponse.class);
+ return ResponseEntity.ok(chatCompletionResponse);
+ }
+
+ /**
+ * 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) {
+ Assert.notNull(chatRequest, "The request body can not be null.");
+ Assert.isTrue(chatRequest.stream(), "Request must set the steam property to true.");
+ AtomicBoolean isInsideTool = new AtomicBoolean(false);
+ String service = HunYuanConstants.DEFAULT_SERVICE;
+ String host = HunYuanConstants.DEFAULT_CHAT_HOST;
+ // String region = "ap-guangzhou";
+ String action = HunYuanConstants.DEFAULT_CHAT_ACTION;
+ MultiValueMap jsonContentHeaders = hunyuanAuthApi.getHttpHeadersConsumer(host, action, service,
+ chatRequest);
+ return this.webClient.post().uri("/").headers(headers -> {
+ headers.addAll(jsonContentHeaders);
+ })
+ .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 -> {
+ // logger.info(content);
+ return 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, null, null, null),
+ (previous, current) -> this.chunkMerger.merge(previous, current));
+ return List.of(monoChunk);
+ })
+ // Flux> -> Flux
+ .flatMap(mono -> mono);
+ }
+
+ /**
+ * 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 model called a tool.
+ */
+ @JsonProperty("tool_calls")
+ TOOL_CALLS;
+
+ private final String jsonValue;
+
+ ChatCompletionFinishReason() {
+ this.jsonValue = this.name().toLowerCase();
+ }
+
+ public String getJsonValue() {
+ return this.jsonValue;
+ }
+
+ }
+
+ /**
+ * HunYuan Chat Completion Models:
+ *
+ *
+ * - HUNYUAN_LITE - hunyuan-lite
+ * - HUNYUAN_STANDARD - hunyuan-standard
+ * - HUNYUAN_STANDARD_256K - hunyuan-standard-256K
+ * - HUNYUAN_PRO - hunyuan-pro
+ * - HUNYUAN_CODE - hunyuan-code
+ * - HUNYUAN_ROLE - hunyuan-role
+ * - HUNYUAN_FUNCTIONCALL - hunyuan-functioncall
+ * - HUNYUAN_VISION - hunyuan-vision
+ * - HUNYUAN_TURBO - hunyuan-turbo
+ * - HUNYUAN_TURBO_LATEST - hunyuan-turbo-latest
+ * - HUNYUAN_LARGE - hunyuan-large
+ * - HUNYUAN_LARGE_LONGCONTEXT - hunyuan-large-longcontext
+ * - HUNYUAN_TURBO_VISION - hunyuan-turbo-vision
+ * - HUNYUAN_STANDARD_VISION - hunyuan-standard-vision
+ *
+ */
+ public enum ChatModel implements ChatModelDescription {
+
+ // @formatter:off
+ HUNYUAN_LITE("hunyuan-lite"),
+ HUNYUAN_STANDARD("hunyuan-standard"),
+ HUNYUAN_STANDARD_256K("hunyuan-standard-256K"),
+ HUNYUAN_PRO("hunyuan-pro"),
+ HUNYUAN_CODE("hunyuan-code"),
+ HUNYUAN_ROLE("hunyuan-role"),
+ HUNYUAN_FUNCTIONCALL("hunyuan-functioncall"),
+ HUNYUAN_VISION("hunyuan-vision"),
+ HUNYUAN_TURBO("hunyuan-turbo"),
+ HUNYUAN_TURBO_LATEST("hunyuan-turbo-latest"),
+ HUNYUAN_LARGE("hunyuan-large"),
+ HUNYUAN_LARGE_LONGCONTEXT("hunyuan-large-longcontext"),
+ HUNYUAN_TURBO_VISION("hunyuan-turbo-vision"),
+ HUNYUAN_STANDARD_VISION("hunyuan-standard-vision");
+ // @formatter:on
+
+ private final String value;
+
+ ChatModel(String value) {
+ this.value = value;
+ }
+
+ public String getValue() {
+ return this.value;
+ }
+
+ @Override
+ public String getName() {
+ return this.value;
+ }
+
+ }
+
+ /**
+ * Usage statistics.
+ *
+ * @param promptTokens Number of tokens in the prompt.
+ * @param totalTokens Total number of tokens used in the request (prompt +
+ * completion).
+ * @param completionTokens Number of tokens in the generated completion. Only
+ * applicable for completion requests.
+ */
+ @JsonInclude(Include.NON_NULL)
+ public record Usage(
+ // @formatter:off
+ @JsonProperty("PromptTokens") Integer promptTokens,
+ @JsonProperty("TotalTokens") Integer totalTokens,
+ @JsonProperty("CompletionTokens") Integer completionTokens) {
+ // @formatter:on
+ }
+
+ /**
+ * Creates a model response for the given chat conversation.
+ *
+ * @param model ID of the model to use.
+ * @param messages A list of messages comprising the conversation so far.
+ * @param temperature What sampling temperature to use, between 0 and 1. 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 enableEnhancement Enables or disables feature enhancements such as search.
+ * This parameter does not affect the security review capability. For hunyuan-lite,
+ * this parameter is ineffective. If not specified, the switch is turned on by
+ * default. Turning off this switch can reduce response latency, especially for the
+ * first character in stream mode, but may slightly degrade the response quality in
+ * some scenarios. Example: true
+ * @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 stop Up to 5 sequences where the API will stop generating further 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 streamModeration Controls whether the output is reviewed in real-time during
+ * streaming. This field is effective only when Stream is set to true. If true, the
+ * output is reviewed in real-time, and segments that fail the review will have their
+ * FinishReason set to sensitive. If false, the entire output is reviewed before being
+ * returned. If real-time text display is required in your application, you should
+ * handle the case where FinishReason is sensitive and providing a custom message.
+ * Example: false
+ * @param tools A list of tools the model may call. Currently, only functions are
+ * supported as a tool.
+ * @param toolChoice Controls which (if any) function is called by the model. Possible
+ * values are none, auto, and custom. If not specified, the default is auto. Example:
+ * auto
+ * @param customTool Forces the model to call a specific tool. This parameter is
+ * required when ToolChoice is set to custom.
+ * @param searchInfo If true, the interface will return SearchInfo when a search hit
+ * occurs. Example: false
+ * @param citation Enables or disables citation markers in the response. This
+ * parameter works in conjunction with EnableEnhancement and SearchInfo. If true,
+ * search results in the response will be marked with a citation marker corresponding
+ * to links in the SearchInfo list. If not specified, the default is false. Example:
+ * false
+ * @param enableSpeedSearch Enables or disables the fast version of search. If true
+ * and a search hit occurs, the fast version of search will be used, which can reduce
+ * the latency of the first character in the stream. Example: false
+ * @param enableMultimedia Enables or disables multimedia capabilities. This parameter
+ * is effective only for whitelisted users and when EnableEnhancement is true and
+ * EnableSpeedSearch is false. For hunyuan-lite, this parameter is ineffective. If not
+ * specified, the default is false. When enabled and a multimedia hit occurs, the
+ * corresponding multimedia address will be output. Example: false
+ * @param enableDeepSearch Enables or disables deep research on the question. If true
+ * and a deep research hit occurs, information about the deep research will be
+ * returned. Example: false
+ * @param seed Ensures the model's output is reproducible. The value should be a
+ * non-zero positive integer, with a maximum value of 10000. It is not recommended to
+ * use this parameter unless necessary, as improper values can affect the output
+ * quality. Example: 1
+ * @param forceSearchEnhancement Forces the use of AI search. If true, AI search will
+ * be used, and if the AI search result is empty, the large model will provide a
+ * fallback response. Example: false
+ * @param enableRecommendedQuestions Enables or disables the recommendation of
+ * additional questions. If true, the response will include a RecommendedQuestions
+ * field with up to 3 recommended questions in the last package. Example: false
+ */
+ @JsonInclude(Include.NON_NULL)
+ public record ChatCompletionRequest(
+ // @formatter:off
+ @JsonProperty("Model") String model,
+ @JsonProperty("Messages") List messages,
+ @JsonProperty("Temperature") Double temperature,
+ @JsonProperty("EnableEnhancement") Boolean enableEnhancement,
+ @JsonProperty("TopP") Double topP,
+ @JsonProperty("Stop") List stop,
+ @JsonProperty("Stream") Boolean stream,
+ @JsonProperty("StreamModeration") Boolean streamModeration,
+ @JsonProperty("Tools") List tools,
+ @JsonProperty("ToolChoice") String toolChoice,
+ @JsonProperty("CustomTool") FunctionTool customTool,
+ @JsonProperty("SearchInfo") Boolean searchInfo,
+ @JsonProperty("Citation") Boolean citation,
+ @JsonProperty("EnableSpeedSearch") Boolean enableSpeedSearch,
+ @JsonProperty("EnableMultimedia") Boolean enableMultimedia,
+ @JsonProperty("EnableDeepSearch") Boolean enableDeepSearch,
+ @JsonProperty("Seed") Integer seed,
+ @JsonProperty("ForceSearchEnhancement") Boolean forceSearchEnhancement,
+ @JsonProperty("EnableRecommendedQuestions") Boolean enableRecommendedQuestions
+ ) {
+ // @formatter:on
+
+ /**
+ * Shortcut constructor for a chat completion request with the given messages and
+ * model.
+ * @param messages The prompt(s) to generate completions for, encoded as a list of
+ * dict with role and content. The first prompt role should be user or system.
+ * @param model ID of the model to use.
+ */
+ public ChatCompletionRequest(List messages, String model) {
+ this(model, messages, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
+ null, null, null);
+ }
+
+ /**
+ * Shortcut constructor for a chat completion request with the given messages,
+ * model and temperature.
+ * @param messages The prompt(s) to generate completions for, encoded as a list of
+ * dict with role and content. The first prompt role should be user or system.
+ * @param model ID of the model to use.
+ * @param temperature What sampling temperature to use, between 0.0 and 1.0.
+ * @param stream Whether to stream back partial progress. If set, tokens will be
+ * sent
+ */
+ public ChatCompletionRequest(List messages, String model, Double temperature,
+ Boolean stream) {
+ this(model, messages, temperature, null, null, null, stream, null, null, null, null, null, null, null, null,
+ null, null, null, null);
+ }
+
+ /**
+ * Shortcut constructor for a chat completion request with the given messages,
+ * model and temperature.
+ * @param messages The prompt(s) to generate completions for, encoded as a list of
+ * dict with role and content. The first prompt role should be user or system.
+ * @param model ID of the model to use.
+ * @param temperature What sampling temperature to use, between 0.0 and 1.0.
+ */
+ public ChatCompletionRequest(List messages, String model, Double temperature) {
+ this(model, messages, temperature, null, null, null, null, null, null, null, null, null, null, null, null,
+ null, null, null, null);
+ }
+
+ /**
+ * Shortcut constructor for a chat completion request with the given messages,
+ * model, tools and tool choice. Streaming is set to false, temperature to 0.8 and
+ * all other parameters are null.
+ * @param messages A list of messages comprising the conversation so far.
+ * @param model ID of the model to use.
+ * @param tools A list of tools the model may call. Currently, only functions are
+ * supported as a tool.
+ * @param toolChoice Controls which (if any) function is called by the model.
+ */
+ public ChatCompletionRequest(List messages, String model, List tools,
+ String toolChoice) {
+ this(model, messages, null, null, null, null, null, null, tools, toolChoice, null, null, null, null, null,
+ null, null, null, null);
+ }
+
+ /**
+ * Shortcut constructor for a chat completion request with the given messages,
+ * model, stream, streamModeration, enableEnhancement, searchInfo, citation,
+ * enableSpeedSearch.
+ * @param messages A list of messages comprising the conversation so far.
+ * @param model ID of the model to use.
+ * @param stream Whether to stream back partial progress.
+ * @param streamModeration Whether to stream back partial progress.
+ * @param enableEnhancement Enables or disables the enhancement feature.
+ * @param searchInfo Enables or disables the search information feature.
+ * @param citation Enables or disables the citation feature.
+ * @param enableSpeedSearch Enables or disables the speed search feature.
+ */
+ public ChatCompletionRequest(List messages, String model, Boolean stream,
+ Boolean streamModeration, Boolean enableEnhancement, Boolean searchInfo, Boolean citation,
+ Boolean enableSpeedSearch) {
+ this(model, messages, null, enableEnhancement, null, null, stream, streamModeration, null, null, null,
+ searchInfo, citation, null, null, enableSpeedSearch, null, null, null);
+ }
+
+ /**
+ * Shortcut constructor for a chat completion request with the given messages and
+ * stream.
+ */
+ public ChatCompletionRequest(List messages, Boolean stream) {
+ this(DEFAULT_CHAT_MODEL, messages, null, null, null, null, stream, null, null, null, null, null, null, null,
+ 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));
+ }
+
+ }
+
+ }
+
+ /**
+ * Message comprising the conversation.
+ *
+ * @param rawContent The raw contents of the message.
+ * @param role The role of the message's author. Could be one of the {@link Role}
+ * types.
+ * @param chatContents The name of the message's author.
+ * @param toolCallId The ID of the tool call associated with the message.
+ * @param toolCalls The list of tool calls associated with the message.
+ */
+ @JsonInclude(Include.NON_NULL)
+ public record ChatCompletionMessage(
+ // @formatter:off
+ @JsonProperty("Content") Object rawContent,
+ @JsonProperty("Role") Role role,
+ @JsonProperty("Contents") List chatContents,
+ @JsonProperty("ToolCallId") String toolCallId,
+ @JsonProperty("ToolCalls") List toolCalls
+ // @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);
+ }
+
+ public ChatCompletionMessage(Object content, Role role, List toolCalls) {
+ this(content, role, null, null, toolCalls);
+ }
+
+ public ChatCompletionMessage(Role role, List chatContent) {
+ this(null, role, chatContent, 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. NOTE: Hunyuan expects the system
+ * message to be before the user message or will fail with 400 error.
+ */
+ public enum Role {
+
+ /**
+ * System message.
+ */
+ @JsonProperty("system")
+ system,
+ /**
+ * User message.
+ */
+ @JsonProperty("user")
+ user,
+ /**
+ * Assistant message.
+ */
+ @JsonProperty("assistant")
+ assistant,
+ /**
+ * Tool message.
+ */
+ @JsonProperty("tool")
+ tool
+ // @formatter:on
+
+ }
+
+ /**
+ * The relevant tool call.
+ *
+ * @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(@JsonProperty("Id") String id, @JsonProperty("Type") String type,
+ @JsonProperty("Index") Integer index, @JsonProperty("Function") ChatCompletionFunction function) {
+
+ }
+
+ @JsonInclude(Include.NON_NULL)
+ public record ChatContent(@JsonProperty("Type") String type, @JsonProperty("Text") String text,
+ @JsonProperty("ImageUrl") ImageUrl imageUrl) {
+ public ChatContent(String type, String text) {
+ this(type, text, null);
+ }
+
+ public ChatContent(String text) {
+ this("text", text, null);
+ }
+
+ public ChatContent(String type, ImageUrl imageUrl) {
+ this(type, null, imageUrl);
+ }
+
+ public ChatContent(ImageUrl imageUrl) {
+ this("image_url", null, imageUrl);
+ }
+
+ }
+
+ @JsonInclude(Include.NON_NULL)
+ public record ImageUrl(@JsonProperty("Url") String url) {
+
+ }
+
+ /**
+ * 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(@JsonProperty("Name") String name,
+ @JsonProperty("Arguments") String arguments) {
+
+ }
+
+ }
+
+ /**
+ * Represents a chat completion response returned by model, based on the provided
+ * input.
+ *
+ * @param response The response object containing the generated chat completion.
+ */
+ @JsonInclude(Include.NON_NULL)
+ public record ChatCompletionResponse(
+ // @formatter:off
+ @JsonProperty("Response") ChatCompletion response
+ ) {
+ // @formatter:on
+ }
+
+ @JsonInclude(Include.NON_NULL)
+ public record ChatCompletion(
+ // @formatter:off
+ @JsonProperty("Id") String id,
+ @JsonProperty("Error") ChatCompletion.ErrorMsg errorMsg,
+ @JsonProperty("Created") Long created,
+ @JsonProperty("Note") String note,
+ @JsonProperty("Choices") List choices,
+ @JsonProperty("Usage") Usage usage,
+ @JsonProperty("ModerationLevel") String moderationLevel,
+ @JsonProperty("SearchInfo") ChatCompletion.SearchInfo searchInfo,
+ @JsonProperty("Replaces") List replaces,
+ @JsonProperty("RecommendedQuestions") List recommendedQuestions,
+ @JsonProperty("RequestId") String requestId
+ ) {
+
+
+
+
+ @JsonInclude(Include.NON_NULL)
+ public record Replace(
+ // @formatter:off
+ @JsonProperty("Id") String id,
+ @JsonProperty("Multimedia") List multimedias
+ ) {
+ // @formatter:on
+ }
+
+ @JsonInclude(Include.NON_NULL)
+ public record Multimedia(
+ // @formatter:off
+ @JsonProperty("Type") String type,
+ @JsonProperty("Url") String url,
+ @JsonProperty("JumpUrl") String jumpUrl,
+ @JsonProperty("Title") String title,
+ @JsonProperty("Desc") String desc,
+ @JsonProperty("Singer") String singer,
+ @JsonProperty("Ext") SongExt ext
+ ) {
+ // @formatter:on
+ }
+
+ @JsonInclude(Include.NON_NULL)
+ public record SongExt(
+ // @formatter:off
+ @JsonProperty("SongId") Integer songId,
+ @JsonProperty("SongMid") String SongMid,
+ @JsonProperty("Vip") Integer Vip
+ ) {
+ // @formatter:on
+ }
+
+ // @formatter:on
+ @JsonInclude(Include.NON_NULL)
+ public record SearchInfo(
+ // @formatter:off
+ @JsonProperty("SearchResults") List searchResults,
+ @JsonProperty("Mindmap") Mindmap mindmap,
+ @JsonProperty("RelevantEvents") List relevantEvents,
+ @JsonProperty("RelevantEntities") List relevantEntities,
+ @JsonProperty("Timeline") List timelines,
+ @JsonProperty("SupportDeepSearch") Boolean supportDeepSearch,
+ @JsonProperty("Outline") List outlines
+ ) {
+ // @formatter:on
+ }
+
+ @JsonInclude(Include.NON_NULL)
+ public record Timeline(
+ // @formatter:off
+ @JsonProperty("Title") String title,
+ @JsonProperty("Datetime") String datetime,
+ @JsonProperty("Url") String url
+ ) {
+ // @formatter:on
+ }
+
+ @JsonInclude(Include.NON_NULL)
+ public record RelevantEntity(
+ // @formatter:off
+ @JsonProperty("Name") String name,
+ @JsonProperty("Content") String content,
+ @JsonProperty("Reference") List reference
+ ) {
+ // @formatter:on
+ }
+
+ @JsonInclude(Include.NON_NULL)
+ public record RelevantEvent(
+ // @formatter:off
+ @JsonProperty("Title") String title,
+ @JsonProperty("Content") String content,
+ @JsonProperty("Datetime") String datetime,
+ @JsonProperty("Reference") List reference
+ ) {
+ // @formatter:on
+ }
+
+ @JsonInclude(Include.NON_NULL)
+ public record Mindmap(
+ // @formatter:off
+ @JsonProperty("ThumbUrl") String thumbUrl,
+ @JsonProperty("Url") String url
+ ) {
+ // @formatter:on
+ }
+
+ @JsonInclude(Include.NON_NULL)
+ public record SearchResults(
+ // @formatter:off
+ @JsonProperty("Index") Integer index,
+ @JsonProperty("Title") String title,
+ @JsonProperty("Url") String url
+ ) {
+ // @formatter:on
+ }
+
+ /**
+ * Chat completion choice.
+ *
+ * @param index The index of the choice in the list of choices.
+ * @param message A chat completion message generated by the model.
+ * @param finishReason The reason the model stopped generating tokens.
+ */
+ @JsonInclude(Include.NON_NULL)
+ public record Choice(
+ // @formatter:off
+ @JsonProperty("Index") Integer index,
+ @JsonProperty("Message") ChatCompletionMessage message,
+ @JsonProperty("FinishReason") String finishReason,
+ @JsonProperty("Delta") ChatCompletionDelta delta
+ ) {
+ // @formatter:on
+ }
+
+ @JsonInclude(Include.NON_NULL)
+ public record ChatCompletionDelta(
+ // @formatter:off
+ @JsonProperty("Role") ChatCompletionMessage.Role role,
+ @JsonProperty("Content") String content,
+ @JsonProperty("ToolCalls") List toolCalls
+ ) {
+ // @formatter:on
+ }
+
+ @JsonInclude(Include.NON_NULL)
+ public record ErrorMsg(
+ // @formatter:off
+ @JsonProperty("Code") String index,
+ @JsonProperty("Message") String message) {
+ // @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 errorMsg The error message, if any.
+ * @param created The Unix timestamp (in seconds) of when the chat completion was
+ * created. Each chunk has the same timestamp.
+ * @param note A note about the generated content. Each chunk has the same note.
+ * @param choices A list of chat completion choices. Can be more than one if n is
+ * greater than 1.
+ * @param usage The usage statistics for the chat completion.
+ */
+ @JsonInclude(Include.NON_NULL)
+ public record ChatCompletionChunk(
+ // @formatter:off
+ @JsonProperty("Id") String id,
+ @JsonProperty("Error") ChatCompletion.ErrorMsg errorMsg,
+ @JsonProperty("Created") Long created,
+ @JsonProperty("Note") String note,
+ @JsonProperty("Choices") List choices,
+ @JsonProperty("Usage") Usage usage,
+ @JsonProperty("ModerationLevel") String moderationLevel,
+ @JsonProperty("SearchInfo") ChatCompletion.SearchInfo searchInfo,
+ @JsonProperty("Replaces") List replaces,
+ @JsonProperty("RecommendedQuestions") List recommendedQuestions,
+ @JsonProperty("RequestId") String requestId) {
+ }
+
+ /**
+ * Represents a tool the model may call. Currently, only functions are supported as a
+ * tool.
+ *
+ * @param type The type of the tool. Currently, only 'function' is supported.
+ * @param function The function definition.
+ */
+ @JsonInclude(Include.NON_NULL)
+ public record FunctionTool(@JsonProperty("Type") Type type, @JsonProperty("Function") 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);
+ }
+
+ /**
+ * Create a tool of type 'function' and the given function definition.
+ */
+ public enum Type {
+
+ /**
+ * Function tool type.
+ */
+ @JsonProperty("function")
+ FUNCTION
+
+ }
+
+ /**
+ * 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": {}}.
+ */
+ public record Function(@JsonProperty("Description") String description, @JsonProperty("Name") String name,
+ @JsonProperty("Parameters") String parameters) {
+ }
+ }
+
+}
diff --git a/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/api/HunYuanConstants.java b/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/api/HunYuanConstants.java
new file mode 100644
index 00000000000..6727a7a2317
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/api/HunYuanConstants.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.hunyuan.api;
+
+import org.springframework.ai.observation.conventions.AiProvider;
+
+/**
+ * Constants for HunYuan API.
+ *
+ * @author Guo Junyu
+ */
+public final class HunYuanConstants {
+
+ public static final String DEFAULT_BASE_URL = "https://hunyuan.tencentcloudapi.com";
+
+ public static final String DEFAULT_CHAT_HOST = "hunyuan.tencentcloudapi.com";
+
+ public static final String PROVIDER_NAME = AiProvider.HUNYUAN.value();
+
+ public static final String DEFAULT_CHAT_ACTION = "ChatCompletions";
+
+ public static final String DEFAULT_VERSION = "2023-09-01";
+
+ public static final String DEFAULT_SERVICE = "hunyuan";
+
+ public static final String DEFAULT_ALGORITHM = "TC3-HMAC-SHA256";
+
+ public static final String CT_JSON = "application/json; charset=utf-8";
+
+ private HunYuanConstants() {
+
+ }
+
+}
diff --git a/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/api/HunYuanStreamFunctionCallingHelper.java b/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/api/HunYuanStreamFunctionCallingHelper.java
new file mode 100644
index 00000000000..1f1b55fd430
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/api/HunYuanStreamFunctionCallingHelper.java
@@ -0,0 +1,167 @@
+/*
+ * 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.hunyuan.api;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.springframework.util.CollectionUtils;
+import org.springframework.ai.hunyuan.api.HunYuanApi.*;
+import org.springframework.ai.hunyuan.api.HunYuanApi.ChatCompletionChunk.*;
+import org.springframework.ai.hunyuan.api.HunYuanApi.ChatCompletionMessage.*;
+import org.springframework.util.StringUtils;
+
+/**
+ * Helper class to support Streaming function calling. It can merge the streamed
+ * ChatCompletionChunk in case of function calling message.
+ *
+ * @author Guo Junyu
+ */
+public class HunYuanStreamFunctionCallingHelper {
+
+ 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 note = (current.note() != null ? current.note() : previous.note());
+
+ ChatCompletion.Choice previousChoice = (CollectionUtils.isEmpty(previous.choices()) ? null
+ : previous.choices().get(0));
+ ChatCompletion.Choice currentChoice = (CollectionUtils.isEmpty(current.choices()) ? null
+ : current.choices().get(0));
+ Usage usage = (current.usage() != null ? current.usage() : previous.usage());
+ ChatCompletion.Choice choice = mergeChoice(previousChoice, currentChoice);
+ List chunkChoices = choice == null ? List.of() : List.of(choice);
+ return new ChatCompletionChunk(id, null, created, note, chunkChoices, usage, null, null, null, null, null);
+ }
+
+ private ChatCompletion.Choice mergeChoice(ChatCompletion.Choice previous, ChatCompletion.Choice current) {
+ if (previous == null) {
+ return current;
+ }
+
+ String finishReason = (current.finishReason() != null ? current.finishReason() : previous.finishReason());
+ Integer index = (current.index() != null ? current.index() : previous.index());
+
+ ChatCompletion.ChatCompletionDelta delta = merge(previous.delta(), current.delta());
+ return new ChatCompletion.Choice(index, null, finishReason, delta);
+ }
+
+ private ChatCompletion.ChatCompletionDelta merge(ChatCompletion.ChatCompletionDelta previous,
+ ChatCompletion.ChatCompletionDelta 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());
+ 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().equals(lastPreviousTooCall.id())) {
+ if (lastPreviousTooCall != null) {
+ toolCalls.add(lastPreviousTooCall);
+ }
+ toolCalls.add(currentToolCall);
+ }
+ else {
+ toolCalls.add(merge(lastPreviousTooCall, currentToolCall));
+ }
+ }
+ else {
+ if (lastPreviousTooCall != null) {
+ toolCalls.add(lastPreviousTooCall);
+ }
+ }
+ return new ChatCompletion.ChatCompletionDelta(role, content, 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());
+ Integer index = (current.index() != null ? current.index() : previous.index());
+ ChatCompletionFunction function = merge(previous.function(), current.function());
+ return new ToolCall(id, type, index, function);
+ }
+
+ private ChatCompletionFunction merge(ChatCompletionFunction previous, ChatCompletionFunction current) {
+ if (previous == null) {
+ return current;
+ }
+ String name = (StringUtils.hasText(current.name()) ? current.name() : previous.name());
+ StringBuilder arguments = new StringBuilder();
+ if (StringUtils.hasText(previous.arguments())) {
+ arguments.append(previous.arguments());
+ }
+ if (StringUtils.hasText(current.arguments())) {
+ 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.getJsonValue();
+ }
+
+}
diff --git a/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/api/auth/HunYuanAuthApi.java b/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/api/auth/HunYuanAuthApi.java
new file mode 100644
index 00000000000..e424863c03a
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/api/auth/HunYuanAuthApi.java
@@ -0,0 +1,173 @@
+package org.springframework.ai.hunyuan.api.auth;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.ai.hunyuan.api.HunYuanApi;
+import org.springframework.ai.hunyuan.api.HunYuanConstants;
+import org.springframework.ai.model.ModelOptionsUtils;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.MultiValueMap;
+
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.text.SimpleDateFormat;
+import java.util.*;
+import java.util.stream.Collectors;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import javax.xml.bind.DatatypeConverter;
+
+import static org.springframework.ai.hunyuan.api.HunYuanConstants.CT_JSON;
+
+/**
+ * The HunYuanAuthApi class is responsible for handling authentication-related operations
+ * for the HunYuan API. It provides methods to generate necessary headers and signatures
+ * required for authenticated requests.
+ *
+ * @author Your Name
+ */
+public class HunYuanAuthApi {
+
+ private static final Logger logger = LoggerFactory.getLogger(HunYuanAuthApi.class);
+
+ private final static Charset UTF8 = StandardCharsets.UTF_8;
+
+ private final String secretId;
+
+ private final String secretKey;
+
+ /**
+ * Constructs a HunYuanAuthApi instance with the specified secret ID and secret key.
+ * @param secretId The secret ID used for authentication.
+ * @param secretKey The secret key used for authentication.
+ */
+ public HunYuanAuthApi(String secretId, String secretKey) {
+ this.secretId = secretId;
+ this.secretKey = secretKey;
+ }
+
+ /**
+ * Generates an HMAC-SHA256 signature using the provided key and message.
+ * @param key The key used for generating the HMAC-SHA256 signature.
+ * @param msg The message to be signed.
+ * @return The byte array of the generated HMAC-SHA256 signature.
+ */
+ public byte[] hmac256(byte[] key, String msg) {
+ Mac mac = null;
+ try {
+ mac = Mac.getInstance("HmacSHA256");
+ SecretKeySpec secretKeySpec = new SecretKeySpec(key, mac.getAlgorithm());
+ mac.init(secretKeySpec);
+ }
+ catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ return mac.doFinal(msg.getBytes(UTF8));
+ }
+
+ /**
+ * Computes the SHA-256 hash of the provided string and returns it as a hexadecimal
+ * string.
+ * @param s The string to be hashed.
+ * @return The SHA-256 hash of the input string in hexadecimal format.
+ */
+ public String sha256Hex(String s) {
+ MessageDigest md = null;
+ try {
+ md = MessageDigest.getInstance("SHA-256");
+ }
+ catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ }
+ byte[] d = md.digest(s.getBytes(UTF8));
+ return DatatypeConverter.printHexBinary(d).toLowerCase();
+ }
+
+ /**
+ * Generates the HTTP headers required for making authenticated requests to the
+ * HunYuan API.
+ * @param host The host address of the API endpoint.
+ * @param action The action to be performed (e.g., "ChatCompletion").
+ * @param service The service name associated with the request.
+ * @param payload The request payload containing the necessary parameters.
+ * @return A MultiValueMap containing the HTTP headers needed for the authenticated
+ * request.
+ */
+ public MultiValueMap getHttpHeadersConsumer(String host, String action, String service,
+ HunYuanApi.ChatCompletionRequest payload) {
+ String version = HunYuanConstants.DEFAULT_VERSION;
+ String algorithm = HunYuanConstants.DEFAULT_ALGORITHM;
+ // String timestamp = "1551113065";
+ String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+ // Pay attention to the time zone, otherwise it will be easy to make mistakes
+ sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
+ String date = sdf.format(new Date(Long.valueOf(timestamp + "000")));
+
+ // ************* Step 1: Splice specification request strings *************
+ String httpRequestMethod = "POST";
+ String canonicalUri = "/";
+ String canonicalQueryString = "";
+ String canonicalHeaders = "content-type:application/json; charset=utf-8\n" + "host:" + host + "\n"
+ + "x-tc-action:" + action.toLowerCase() + "\n";
+ String signedHeaders = "content-type;host;x-tc-action";
+
+ // String payload = "{\"Limit\": 1, \"Filters\": [{\"Values\":
+ // [\"\\u672a\\u547d\\u540d\"], \"Name\": \"instance-name\"}]}";
+ String payloadString = ModelOptionsUtils.toJsonString(payload);
+ String hashedRequestPayload = sha256Hex(payloadString);
+ String canonicalRequest = httpRequestMethod + "\n" + canonicalUri + "\n" + canonicalQueryString + "\n"
+ + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestPayload;
+ // ************* Step 2: Splice the string to be signed *************
+ String credentialScope = date + "/" + service + "/" + "tc3_request";
+ String hashedCanonicalRequest = sha256Hex(canonicalRequest);
+ String stringToSign = algorithm + "\n" + timestamp + "\n" + credentialScope + "\n" + hashedCanonicalRequest;
+ // ************* Step 3: Calculate the signature *************
+ byte[] secretDate = hmac256(("TC3" + secretKey).getBytes(UTF8), date);
+ byte[] secretService = hmac256(secretDate, service);
+ byte[] secretSigning = hmac256(secretService, "tc3_request");
+ String signature = DatatypeConverter.printHexBinary(hmac256(secretSigning, stringToSign)).toLowerCase();
+ // ************* Step 4: Splice Authorization *************
+ String authorization = algorithm + " " + "Credential=" + secretId + "/" + credentialScope + ", "
+ + "SignedHeaders=" + signedHeaders + ", " + "Signature=" + signature;
+
+ TreeMap headers = new TreeMap();
+ headers.put("Authorization", authorization);
+ headers.put("Content-Type", CT_JSON);
+ // headers.put("Host", host);
+ headers.put("X-TC-Action", action);
+ headers.put("X-TC-Timestamp", timestamp);
+ headers.put("X-TC-Version", version);
+
+ if (logger.isDebugEnabled()) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("curl -X POST https://")
+ .append(host)
+ .append(" -H \"Authorization: ")
+ .append(authorization)
+ .append("\"")
+ .append(" -H \"Content-Type: application/json; charset=utf-8\"")
+ .append(" -H \"Host: ")
+ .append(host)
+ .append("\"")
+ .append(" -H \"X-TC-Action: ")
+ .append(action)
+ .append("\"")
+ .append(" -H \"X-TC-Timestamp: ")
+ .append(timestamp)
+ .append("\"")
+ .append(" -H \"X-TC-Version: ")
+ .append(version)
+ .append("\"")
+ .append(" -d '")
+ .append(payloadString)
+ .append("'");
+ logger.debug(sb.toString());
+ }
+ return CollectionUtils.toMultiValueMap(
+ headers.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, e -> List.of(e.getValue()))));
+ }
+
+}
diff --git a/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/metadata/HunYuanUsage.java b/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/metadata/HunYuanUsage.java
new file mode 100644
index 00000000000..b6ad6826415
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/metadata/HunYuanUsage.java
@@ -0,0 +1,65 @@
+/*
+ * 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.hunyuan.metadata;
+
+import org.springframework.ai.chat.metadata.Usage;
+import org.springframework.ai.hunyuan.api.HunYuanApi;
+import org.springframework.util.Assert;
+
+/**
+ * Represents the usage of a HunYuan model.
+ *
+ * @author Guo Junyu
+ */
+public class HunYuanUsage implements Usage {
+
+ private final HunYuanApi.Usage usage;
+
+ protected HunYuanUsage(HunYuanApi.Usage usage) {
+ Assert.notNull(usage, "Hunyuan Usage must not be null");
+ this.usage = usage;
+ }
+
+ public static HunYuanUsage from(HunYuanApi.Usage usage) {
+ return new HunYuanUsage(usage);
+ }
+
+ protected HunYuanApi.Usage getUsage() {
+ return this.usage;
+ }
+
+ @Override
+ public Long getPromptTokens() {
+ return getUsage().promptTokens().longValue();
+ }
+
+ @Override
+ public Long getGenerationTokens() {
+ return getUsage().completionTokens().longValue();
+ }
+
+ @Override
+ public Long getTotalTokens() {
+ return getUsage().totalTokens().longValue();
+ }
+
+ @Override
+ public String toString() {
+ return getUsage().toString();
+ }
+
+}
diff --git a/models/spring-ai-hunyuan/src/main/resources/META-INF/spring/aot.factories b/models/spring-ai-hunyuan/src/main/resources/META-INF/spring/aot.factories
new file mode 100644
index 00000000000..59a1031233b
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/main/resources/META-INF/spring/aot.factories
@@ -0,0 +1,2 @@
+org.springframework.aot.hint.RuntimeHintsRegistrar=\
+ org.springframework.ai.hunyuan.aot.HunYuanRuntimeHints
\ No newline at end of file
diff --git a/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/HunYuanChatCompletionRequestTest.java b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/HunYuanChatCompletionRequestTest.java
new file mode 100644
index 00000000000..c21caf42564
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/HunYuanChatCompletionRequestTest.java
@@ -0,0 +1,58 @@
+/*
+ * 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.hunyuan;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.hunyuan.api.HunYuanApi;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Guo Junyu
+ */
+@SpringBootTest
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_ID", matches = ".+")
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_KEY", matches = ".+")
+public class HunYuanChatCompletionRequestTest {
+
+ HunYuanChatModel chatModel = new HunYuanChatModel(
+ new HunYuanApi(System.getenv("HUNYUAN_SECRET_ID"), System.getenv("HUNYUAN_SECRET_KEY")));
+
+ @Test
+ void chatCompletionDefaultRequestTest() {
+ var request = this.chatModel.createRequest(new Prompt("test content"), false);
+
+ assertThat(request.messages()).hasSize(1);
+ assertThat(request.stream()).isFalse();
+ }
+
+ @Test
+ void chatCompletionRequestWithOptionsTest() {
+ var options = HunYuanChatOptions.builder().temperature(0.5).topP(0.8).build();
+ var request = this.chatModel.createRequest(new Prompt("test content", options), true);
+
+ assertThat(request.messages().size()).isEqualTo(1);
+ assertThat(request.topP()).isEqualTo(0.8);
+ assertThat(request.temperature()).isEqualTo(0.5);
+ assertThat(request.stream()).isTrue();
+ }
+
+}
diff --git a/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/HunYuanRetryTests.java b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/HunYuanRetryTests.java
new file mode 100644
index 00000000000..bee2faaa158
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/HunYuanRetryTests.java
@@ -0,0 +1,154 @@
+/*
+ * 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.hunyuan;
+
+import java.util.List;
+import java.util.Optional;
+
+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.hunyuan.api.HunYuanApi;
+import reactor.core.publisher.Flux;
+
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.hunyuan.api.HunYuanApi.ChatCompletion;
+import org.springframework.ai.hunyuan.api.HunYuanApi.ChatCompletionChunk;
+import org.springframework.ai.hunyuan.api.HunYuanApi.ChatCompletionFinishReason;
+import org.springframework.ai.hunyuan.api.HunYuanApi.ChatCompletionMessage;
+import org.springframework.ai.hunyuan.api.HunYuanApi.ChatCompletionMessage.Role;
+import org.springframework.ai.hunyuan.api.HunYuanApi.ChatCompletionRequest;
+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 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 Guo Junyu
+ */
+@SuppressWarnings("unchecked")
+@ExtendWith(MockitoExtension.class)
+public class HunYuanRetryTests {
+
+ private TestRetryListener retryListener;
+
+ private @Mock HunYuanApi hunYuanApi;
+
+ private HunYuanChatModel chatModel;
+
+ @BeforeEach
+ public void beforeEach() {
+ RetryTemplate retryTemplate = RetryUtils.SHORT_RETRY_TEMPLATE;
+ this.retryListener = new TestRetryListener();
+ retryTemplate.registerListener(this.retryListener);
+
+ this.chatModel = new HunYuanChatModel(this.hunYuanApi,
+ HunYuanChatOptions.builder()
+ .temperature(0.7)
+ .topP(1.0)
+ .model(HunYuanApi.ChatModel.HUNYUAN_PRO.getValue())
+ .build(),
+ null, retryTemplate);
+ }
+
+ @Test
+ public void hunyuanChatTransientError() {
+
+ var choice = new ChatCompletion.Choice(0, new ChatCompletionMessage("Response123", Role.assistant),
+ ChatCompletionFinishReason.STOP.name(), null);
+ ChatCompletion expectedChatCompletion = new ChatCompletion("id", null, 789L, "model", List.of(choice),
+ new HunYuanApi.Usage(10, 10, 10), null, null, null, null, null);
+ HunYuanApi.ChatCompletionResponse chatCompletionResponse = new HunYuanApi.ChatCompletionResponse(
+ expectedChatCompletion);
+
+ given(this.hunYuanApi.chatCompletionEntity(isA(ChatCompletionRequest.class)))
+ .willThrow(new TransientAiException("Transient Error 1"))
+ .willThrow(new TransientAiException("Transient Error 2"))
+ .willReturn(ResponseEntity.of(Optional.of(chatCompletionResponse)));
+
+ var result = this.chatModel.call(new Prompt("text"));
+
+ assertThat(result).isNotNull();
+ assertThat(result.getResult().getOutput().getText()).isSameAs("Response123");
+ assertThat(this.retryListener.onSuccessRetryCount).isEqualTo(2);
+ assertThat(this.retryListener.onErrorRetryCount).isEqualTo(2);
+ }
+
+ @Test
+ public void hunYuanChatNonTransientError() {
+ given(this.hunYuanApi.chatCompletionEntity(isA(ChatCompletionRequest.class)))
+ .willThrow(new RuntimeException("Non Transient Error"));
+ assertThrows(RuntimeException.class, () -> this.chatModel.call(new Prompt("text")));
+ }
+
+ @Test
+ public void hunYuanChatStreamTransientError() {
+
+ var choice = new ChatCompletion.Choice(0, null, ChatCompletionFinishReason.STOP.name(),
+ new ChatCompletion.ChatCompletionDelta(Role.assistant, "Response123", null));
+ ChatCompletionChunk expectedChatCompletion = new ChatCompletionChunk("id", null, 789L, "model", List.of(choice),
+ null, null, null, null, null, null);
+ given(this.hunYuanApi.chatCompletionStream(isA(ChatCompletionRequest.class)))
+ .willThrow(new TransientAiException("Transient Error 1"))
+ .willThrow(new TransientAiException("Transient Error 2"))
+ .willReturn(Flux.just(expectedChatCompletion));
+
+ var result = this.chatModel.stream(new Prompt("text"));
+
+ assertThat(result).isNotNull();
+ assertThat(result.collectList().block().get(0).getResult().getOutput().getText()).isSameAs("Response123");
+ assertThat(this.retryListener.onSuccessRetryCount).isEqualTo(2);
+ assertThat(this.retryListener.onErrorRetryCount).isEqualTo(2);
+ }
+
+ @Test
+ public void hunYuanChatStreamNonTransientError() {
+ given(this.hunYuanApi.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-hunyuan/src/test/java/org/springframework/ai/hunyuan/HunYuanTestConfiguration.java b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/HunYuanTestConfiguration.java
new file mode 100644
index 00000000000..a82f7304e7e
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/HunYuanTestConfiguration.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.hunyuan;
+
+import org.springframework.ai.hunyuan.api.HunYuanApi;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.util.StringUtils;
+
+/**
+ * @author Guo Junyu
+ */
+@SpringBootConfiguration
+public class HunYuanTestConfiguration {
+
+ @Bean
+ public HunYuanApi hunYuanApi() {
+ var secretId = System.getenv("HUNYUAN_SECRET_ID");
+ var secretKey = System.getenv("HUNYUAN_SECRET_KEY");
+ if (!StringUtils.hasText(secretId) && !StringUtils.hasText(secretKey)) {
+ throw new IllegalArgumentException(
+ "Missing HUNYUAN_SECRET_ID & HUNYUAN_SECRET_KEY environment variable. Please set it to your HUNYUAN API info.");
+ }
+ return new HunYuanApi(secretId, secretKey);
+ }
+
+ @Bean
+ public HunYuanChatModel hunYuanChatModel(HunYuanApi hunYuanApi) {
+ return new HunYuanChatModel(hunYuanApi);
+ }
+
+}
diff --git a/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/aot/HunYuanRuntimeHintsTests.java b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/aot/HunYuanRuntimeHintsTests.java
new file mode 100644
index 00000000000..71e2578e896
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/aot/HunYuanRuntimeHintsTests.java
@@ -0,0 +1,49 @@
+/*
+ * 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.hunyuan.aot;
+
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.ai.hunyuan.api.HunYuanApi;
+import org.springframework.ai.hunyuan.aot.HunYuanRuntimeHints;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.TypeReference;
+
+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 Guo Junyu
+ */
+class HunYuanRuntimeHintsTests {
+
+ @Test
+ void registerHints() {
+ RuntimeHints runtimeHints = new RuntimeHints();
+ HunYuanRuntimeHints hunYuanRuntimeHints = new HunYuanRuntimeHints();
+ hunYuanRuntimeHints.registerHints(runtimeHints, null);
+
+ Set jsonAnnotatedClasses = findJsonAnnotatedClassesInPackage(HunYuanApi.class);
+ for (TypeReference jsonAnnotatedClass : jsonAnnotatedClasses) {
+ assertThat(runtimeHints).matches(reflection().onType(jsonAnnotatedClass));
+ }
+ }
+
+}
diff --git a/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/api/HunYuanApiIT.java b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/api/HunYuanApiIT.java
new file mode 100644
index 00000000000..511c91730ab
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/api/HunYuanApiIT.java
@@ -0,0 +1,228 @@
+/*
+ * 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.hunyuan.api;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Base64;
+import java.util.List;
+
+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.model.ModelOptionsUtils;
+import org.springframework.core.io.ClassPathResource;
+import reactor.core.publisher.Flux;
+
+import org.springframework.ai.hunyuan.api.HunYuanApi.ChatCompletionChunk;
+import org.springframework.ai.hunyuan.api.HunYuanApi.ChatCompletionMessage;
+import org.springframework.ai.hunyuan.api.HunYuanApi.ChatCompletionMessage.Role;
+import org.springframework.ai.hunyuan.api.HunYuanApi.ChatCompletionRequest;
+import org.springframework.http.ResponseEntity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Guo Junyu
+ */
+
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_ID", matches = ".+")
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_KEY", matches = ".+")
+public class HunYuanApiIT {
+
+ private static final Logger logger = LoggerFactory.getLogger(HunYuanApiIT.class);
+
+ HunYuanApi hunyuanApi = new HunYuanApi(System.getenv("HUNYUAN_SECRET_ID"), System.getenv("HUNYUAN_SECRET_KEY"));
+
+ @Test
+ void chatCompletionEntity() {
+ ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage("Hello World!", Role.user);
+ ResponseEntity response = this.hunyuanApi
+ .chatCompletionEntity(new ChatCompletionRequest(List.of(chatCompletionMessage),
+ HunYuanApi.ChatModel.HUNYUAN_PRO.getValue(), 0.8, false));
+
+ assertThat(response).isNotNull();
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().response()).isNotNull();
+ logger.info(response.getBody().response().toString());
+ // System.out.println(response.getBody().response().errorMsg().message());
+ }
+
+ @Test
+ void chatCompletionEntityByEnhance() {
+ ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage("Why is the price of gold rising?",
+ Role.user);
+ ResponseEntity response = this.hunyuanApi
+ .chatCompletionEntity(new ChatCompletionRequest(List.of(chatCompletionMessage),
+ HunYuanApi.ChatModel.HUNYUAN_PRO.getValue(), false, false, true, true, true, true));
+
+ assertThat(response).isNotNull();
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().response()).isNotNull();
+ logger.info(response.getBody().response().toString());
+ // System.out.println(response.getBody().response().errorMsg().message());
+ }
+
+ @Test
+ void chatCompletionEntityWithSystemMessage() {
+ ChatCompletionMessage userMessage = new ChatCompletionMessage(
+ "Tell me about 3 famous pirates from the Golden Age of Piracy and why they did?", Role.user);
+ ChatCompletionMessage systemMessage = new ChatCompletionMessage("""
+ You are an AI assistant that helps people find information.
+ Your name is Bob.
+ You should reply to the user's request with your name and also in the style of a pirate.
+ """, Role.system);
+
+ ResponseEntity response = this.hunyuanApi
+ .chatCompletionEntity(new ChatCompletionRequest(List.of(systemMessage, userMessage),
+ HunYuanApi.ChatModel.HUNYUAN_PRO.getValue(), 0.8, false));
+
+ assertThat(response).isNotNull();
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().response()).isNotNull();
+ // System.out.println(response.getBody().response().choices().get(0).message().content());
+ }
+
+ @Test
+ void chatCompletionEntityWithPicture() {
+ ChatCompletionMessage userMessage = new ChatCompletionMessage(Role.user, List.of(
+ new ChatCompletionMessage.ChatContent("text", "Which company's logo is in the picture below?"),
+ new ChatCompletionMessage.ChatContent("image_url", new ChatCompletionMessage.ImageUrl(
+ "https://cloudcache.tencent-cloud.com/qcloud/ui/portal-set/build/About/images/bg-product-series_87d.png"))));
+ ResponseEntity response = this.hunyuanApi
+ .chatCompletionEntity(new ChatCompletionRequest(List.of(userMessage),
+ HunYuanApi.ChatModel.HUNYUAN_TURBO_VISION.getValue(), 0.8, false));
+
+ logger.info(response.getBody().response().toString());
+ assertThat(response).isNotNull();
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().response()).isNotNull();
+ // System.out.println(response.getBody().response().choices().get(0).message().content());
+ }
+
+ @Test
+ void chatCompletionEntityWithNativePicture() {
+ String imageInfo = "data:image/jpeg;base64,";
+ // 读取图片文件
+ var imageData = new ClassPathResource("/img.png");
+ try (InputStream inputStream = imageData.getInputStream()) {
+ byte[] imageBytes = inputStream.readAllBytes();
+ // 使用Base64编码图片字节数据
+ String encodedImage = Base64.getEncoder().encodeToString(imageBytes);
+ // 输出编码后的字符串
+ imageInfo += encodedImage;
+ }
+ catch (IOException e) {
+ e.printStackTrace();
+ }
+ ChatCompletionMessage userMessage = new ChatCompletionMessage(Role.user, List.of(
+ new ChatCompletionMessage.ChatContent("text", "Which company's logo is in the picture below?"),
+ new ChatCompletionMessage.ChatContent("image_url", new ChatCompletionMessage.ImageUrl(imageInfo))));
+ ResponseEntity response = this.hunyuanApi
+ .chatCompletionEntity(new ChatCompletionRequest(List.of(userMessage),
+ HunYuanApi.ChatModel.HUNYUAN_TURBO_VISION.getValue(), 0.8, false));
+
+ logger.info(response.getBody().response().toString());
+ assertThat(response).isNotNull();
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().response()).isNotNull();
+ }
+
+ @Test
+ void chatCompletionStream() {
+ ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage("Hello world", Role.user);
+ Flux response = this.hunyuanApi.chatCompletionStream(new ChatCompletionRequest(
+ List.of(chatCompletionMessage), HunYuanApi.ChatModel.HUNYUAN_PRO.getValue(), 0.8, true));
+
+ assertThat(response).isNotNull();
+ assertThat(response.collectList().block()).isNotNull();
+ logger.info(ModelOptionsUtils.toJsonString(response.collectList().block()));
+ }
+
+ @Test
+ void chatCompletionEntityByEnhanced() {
+ ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage("你好!", Role.user);
+ ResponseEntity response = this.hunyuanApi
+ .chatCompletionEntity(new ChatCompletionRequest(List.of(chatCompletionMessage),
+ HunYuanApi.ChatModel.HUNYUAN_PRO.getValue(), 0.8, false));
+
+ assertThat(response).isNotNull();
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().response()).isNotNull();
+ logger.info(response.getBody().response().toString());
+ // System.out.println(response.getBody().response().errorMsg().message());
+ }
+
+ @Test
+ void chatCompletionStreamWithSystemMessage() {
+ ChatCompletionMessage userMessage = new ChatCompletionMessage(
+ "Tell me about 3 famous pirates from the Golden Age of Piracy and why they did?", Role.user);
+ ChatCompletionMessage systemMessage = new ChatCompletionMessage("""
+ You are an AI assistant that helps people find information.
+ Your name is Bob.
+ You should reply to the user's request with your name and also in the style of a pirate.
+ """, Role.system);
+ Flux response = this.hunyuanApi.chatCompletionStream(new ChatCompletionRequest(
+ List.of(systemMessage, userMessage), HunYuanApi.ChatModel.HUNYUAN_PRO.getValue(), 0.8, true));
+
+ assertThat(response).isNotNull();
+ assertThat(response.collectList().block()).isNotNull();
+ logger.info(ModelOptionsUtils.toJsonString(response.collectList().block()));
+ }
+
+ @Test
+ void chatCompletionStreamWithPicture() {
+ ChatCompletionMessage userMessage = new ChatCompletionMessage(Role.user, List.of(
+ new ChatCompletionMessage.ChatContent("text", "Which company's logo is in the picture below?"),
+ new ChatCompletionMessage.ChatContent("image_url", new ChatCompletionMessage.ImageUrl(
+ "https://cloudcache.tencent-cloud.com/qcloud/ui/portal-set/build/About/images/bg-product-series_87d.png"))));
+ Flux response = this.hunyuanApi.chatCompletionStream(new ChatCompletionRequest(
+ List.of(userMessage), HunYuanApi.ChatModel.HUNYUAN_TURBO_VISION.getValue(), 0.8, true));
+
+ assertThat(response).isNotNull();
+ assertThat(response.collectList().block()).isNotNull();
+ logger.info(ModelOptionsUtils.toJsonString(response.collectList().block()));
+ }
+
+ @Test
+ void chatCompletionStreamWithNativePicture() {
+ String imageInfo = "data:image/jpeg;base64,";
+ // 读取图片文件
+ var imageData = new ClassPathResource("/img.png");
+ try (InputStream inputStream = imageData.getInputStream()) {
+ byte[] imageBytes = inputStream.readAllBytes();
+ // 使用Base64编码图片字节数据
+ String encodedImage = Base64.getEncoder().encodeToString(imageBytes);
+ // 输出编码后的字符串
+ imageInfo += encodedImage;
+ }
+ catch (IOException e) {
+ e.printStackTrace();
+ }
+ ChatCompletionMessage userMessage = new ChatCompletionMessage(Role.user, List.of(
+ new ChatCompletionMessage.ChatContent("text", "Which company's logo is in the picture below?"),
+ new ChatCompletionMessage.ChatContent("image_url", new ChatCompletionMessage.ImageUrl(imageInfo))));
+ Flux response = this.hunyuanApi.chatCompletionStream(new ChatCompletionRequest(
+ List.of(userMessage), HunYuanApi.ChatModel.HUNYUAN_TURBO_VISION.getValue(), 0.8, true));
+
+ assertThat(response).isNotNull();
+ assertThat(response.collectList().block()).isNotNull();
+ logger.info(ModelOptionsUtils.toJsonString(response.collectList().block()));
+ }
+
+}
diff --git a/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/api/HunYuanApiToolFunctionCallIT.java b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/api/HunYuanApiToolFunctionCallIT.java
new file mode 100644
index 00000000000..176cb9a8122
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/api/HunYuanApiToolFunctionCallIT.java
@@ -0,0 +1,147 @@
+/*
+ * 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.hunyuan.api;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+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.hunyuan.api.HunYuanApi.ChatCompletion;
+import org.springframework.ai.hunyuan.api.HunYuanApi.ChatCompletionMessage;
+import org.springframework.ai.hunyuan.api.HunYuanApi.ChatCompletionMessage.Role;
+import org.springframework.ai.hunyuan.api.HunYuanApi.ChatCompletionMessage.ToolCall;
+import org.springframework.ai.hunyuan.api.HunYuanApi.ChatCompletionRequest;
+import org.springframework.ai.hunyuan.api.HunYuanApi.ChatCompletionRequest.ToolChoiceBuilder;
+import org.springframework.ai.hunyuan.api.HunYuanApi.FunctionTool;
+import org.springframework.ai.hunyuan.api.HunYuanApi.FunctionTool.Type;
+import org.springframework.ai.model.ModelOptionsUtils;
+import org.springframework.http.ResponseEntity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Guo Junyu
+ */
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_ID", matches = ".+")
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_KEY", matches = ".+")
+public class HunYuanApiToolFunctionCallIT {
+
+ private static final FunctionTool FUNCTION_TOOL = new FunctionTool(Type.FUNCTION, new 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"]
+ }
+ """));
+
+ private final Logger logger = LoggerFactory.getLogger(HunYuanApiToolFunctionCallIT.class);
+
+ private final MockWeatherService weatherService = new MockWeatherService();
+
+ private final HunYuanApi hunyuanApi = new HunYuanApi(System.getenv("HUNYUAN_SECRET_ID"),
+ System.getenv("HUNYUAN_SECRET_KEY"));
+
+ @SuppressWarnings("null")
+ @Test
+ public void toolFunctionCall() {
+ toolFunctionCall("What's the weather like in San Francisco? Return the temperature in Celsius.",
+ "San Francisco");
+ }
+
+ @Test
+ public void toolFunctionCallChinese() {
+ toolFunctionCall("旧金山、东京和巴黎的气温怎么样? 返回摄氏度的温度", "旧金山");
+ }
+
+ private void toolFunctionCall(String userMessage, String cityName) {
+ // Step 1: send the conversation and available functions to the model
+ var message = new ChatCompletionMessage(userMessage, Role.user);
+
+ List messages = new ArrayList<>(List.of(message));
+
+ ChatCompletionRequest chatCompletionRequest = new ChatCompletionRequest(messages,
+ HunYuanApi.ChatModel.HUNYUAN_PRO.getValue(), List.of(FUNCTION_TOOL), ToolChoiceBuilder.AUTO);
+
+ ResponseEntity chatCompletion = this.hunyuanApi
+ .chatCompletionEntity(chatCompletionRequest);
+
+ assertThat(chatCompletion.getBody()).isNotNull();
+ assertThat(chatCompletion.getBody().response().choices()).isNotEmpty();
+
+ ChatCompletionMessage responseMessage = chatCompletion.getBody().response().choices().get(0).message();
+
+ assertThat(responseMessage.role()).isEqualTo(Role.assistant);
+ assertThat(responseMessage.toolCalls()).isNotNull();
+
+ messages.add(responseMessage);
+
+ // Send the info for each function call and function response to the model.
+ for (ToolCall toolCall : responseMessage.toolCalls()) {
+ var functionName = toolCall.function().name();
+ if ("getCurrentWeather".equals(functionName)) {
+ MockWeatherService.Request weatherRequest = ModelOptionsUtils
+ .jsonToObject(toolCall.function().arguments(), MockWeatherService.Request.class);
+
+ MockWeatherService.Response weatherResponse = this.weatherService.apply(weatherRequest);
+
+ // extend conversation with function response.
+ messages.add(new ChatCompletionMessage("温度为:" + weatherResponse.temp() + weatherRequest.unit(),
+ Role.tool, null, toolCall.id(), null));
+ }
+ }
+
+ var functionResponseRequest = new ChatCompletionRequest(messages, HunYuanApi.ChatModel.HUNYUAN_PRO.getValue(),
+ 0.5);
+
+ ResponseEntity chatCompletion2 = this.hunyuanApi
+ .chatCompletionEntity(functionResponseRequest);
+
+ logger.info("Final response: " + chatCompletion2.getBody());
+
+ assertThat(Objects.requireNonNull(chatCompletion2.getBody()).response().choices()).isNotEmpty();
+
+ assertThat(chatCompletion2.getBody().response().choices().get(0).message().role()).isEqualTo(Role.assistant);
+ assertThat(chatCompletion2.getBody().response().choices().get(0).message().content()).contains(cityName)
+ .containsAnyOf("30");
+ }
+
+}
diff --git a/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/api/MockWeatherService.java b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/api/MockWeatherService.java
new file mode 100644
index 00000000000..d6bfd7f22a5
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/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.hunyuan.api;
+
+import java.util.function.Function;
+
+import com.fasterxml.jackson.annotation.JsonClassDescription;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonInclude.Include;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyDescription;
+
+/**
+ * @author Guo Junyu
+ */
+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-hunyuan/src/test/java/org/springframework/ai/hunyuan/chat/ActorsFilms.java b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/chat/ActorsFilms.java
new file mode 100644
index 00000000000..86109aefbf6
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/chat/ActorsFilms.java
@@ -0,0 +1,54 @@
+/*
+ * 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.hunyuan.chat;
+
+import java.util.List;
+
+/**
+ * @author Guo Junyu
+ */
+public class ActorsFilms {
+
+ private String actor;
+
+ private List movies;
+
+ public ActorsFilms() {
+ }
+
+ public String getActor() {
+ return this.actor;
+ }
+
+ public void setActor(String actor) {
+ this.actor = actor;
+ }
+
+ public List getMovies() {
+ return this.movies;
+ }
+
+ public void setMovies(List movies) {
+ this.movies = movies;
+ }
+
+ @Override
+ public String toString() {
+ return "ActorsFilms{" + "actor='" + this.actor + '\'' + ", movies=" + this.movies + '}';
+ }
+
+}
diff --git a/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/chat/HunYuanChatModelFunctionCallingIT.java b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/chat/HunYuanChatModelFunctionCallingIT.java
new file mode 100644
index 00000000000..e9c95aa5355
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/chat/HunYuanChatModelFunctionCallingIT.java
@@ -0,0 +1,116 @@
+/*
+ * 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.hunyuan.chat;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Flux;
+
+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.model.function.FunctionCallback;
+import org.springframework.ai.hunyuan.HunYuanChatOptions;
+import org.springframework.ai.hunyuan.HunYuanTestConfiguration;
+import org.springframework.ai.hunyuan.api.MockWeatherService;
+import org.springframework.ai.hunyuan.api.HunYuanApi;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Guo Junyu
+ */
+@SpringBootTest(classes = HunYuanTestConfiguration.class)
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_ID", matches = ".+")
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_KEY", matches = ".+")
+class HunYuanChatModelFunctionCallingIT {
+
+ private static final Logger logger = LoggerFactory.getLogger(HunYuanChatModelFunctionCallingIT.class);
+
+ @Autowired
+ ChatModel chatModel;
+
+ @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 = HunYuanChatOptions.builder()
+ .model(HunYuanApi.ChatModel.HUNYUAN_PRO.getValue())
+ .functionCallbacks(List.of(FunctionCallback.builder()
+ .function("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 = HunYuanChatOptions.builder()
+ .functionCallbacks(List.of(FunctionCallback.builder()
+ .function("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");
+ }
+
+}
diff --git a/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/chat/HunYuanChatModelIT.java b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/chat/HunYuanChatModelIT.java
new file mode 100644
index 00000000000..ccde90147a9
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/chat/HunYuanChatModelIT.java
@@ -0,0 +1,286 @@
+/*
+ * 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.hunyuan.chat;
+
+import java.io.IOException;
+import java.net.URL;
+import java.util.*;
+import java.util.stream.Collectors;
+
+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.hunyuan.HunYuanChatOptions;
+import org.springframework.ai.hunyuan.HunYuanTestConfiguration;
+import org.springframework.ai.hunyuan.api.HunYuanApi;
+import org.springframework.ai.model.Media;
+import org.springframework.ai.model.ModelOptionsUtils;
+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.ByteArrayResource;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.io.PathResource;
+import org.springframework.core.io.Resource;
+import org.springframework.http.MediaType;
+import org.springframework.util.MimeTypeUtils;
+import org.springframework.util.StreamUtils;
+import reactor.core.publisher.Flux;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Guo Junyu
+ */
+@SpringBootTest(classes = HunYuanTestConfiguration.class)
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_ID", matches = ".+")
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_KEY", matches = ".+")
+public class HunYuanChatModelIT {
+
+ private static final Logger logger = LoggerFactory.getLogger(HunYuanChatModelIT.class);
+
+ @Autowired
+ protected ChatModel chatModel;
+
+ @Autowired
+ protected StreamingChatModel streamingChatModel;
+
+ @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 why they did.");
+ SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(this.systemResource);
+ Message systemMessage = systemPromptTemplate.createMessage(Map.of("name", "Bob", "voice", "pirate"));
+ Prompt prompt = new Prompt(List.of(userMessage, systemMessage));
+ ChatResponse response = this.chatModel.call(prompt);
+ assertThat(response.getResults()).hasSize(1);
+ assertThat(response.getResults().get(0).getOutput().getText()).contains("Blackbeard");
+ }
+
+ @Test
+ void nativePictureTest() {
+ List media = new ArrayList<>();
+
+ var imageData = new ClassPathResource("/img.png");
+ media.add(new Media(MediaType.IMAGE_PNG, imageData));
+ UserMessage userMessage = new UserMessage("Which company's logo is in the picture below?", media);
+ Prompt prompt = new Prompt(List.of(userMessage),
+ HunYuanChatOptions.builder().model(HunYuanApi.ChatModel.HUNYUAN_TURBO_VISION.getName()).build());
+ ChatResponse response = this.chatModel.call(prompt);
+ assertThat(response.getResults()).hasSize(1);
+ assertThat(response.getResults().get(0).getOutput().getText()).contains("cloud");
+ }
+
+ @Test
+ void nativePictureStreamTest() {
+ List media = new ArrayList<>();
+
+ var imageData = new ClassPathResource("/img.png");
+ media.add(new Media(MediaType.IMAGE_PNG, imageData));
+ UserMessage userMessage = new UserMessage("Which company's logo is in the picture below?", media);
+ Prompt prompt = new Prompt(List.of(userMessage),
+ HunYuanChatOptions.builder().model(HunYuanApi.ChatModel.HUNYUAN_TURBO_VISION.getName()).build());
+ Flux response = this.chatModel.stream(prompt);
+ String content = Objects.requireNonNull(response.collectList().block())
+ .stream()
+ .map(ChatResponse::getResults)
+ .flatMap(List::stream)
+ .map(Generation::getOutput)
+ .map(AssistantMessage::getText)
+ .collect(Collectors.joining());
+ logger.info("Response: {}", content);
+ assertThat(content).contains("cloud");
+ }
+
+ @Test
+ void cloudPictureTest() throws IOException {
+ UserMessage userMessage = new UserMessage("Which company's logo is in the picture below?", List.of(Media
+ .builder()
+ .mimeType(MimeTypeUtils.IMAGE_PNG)
+ .data(new URL(
+ "https://cloudcache.tencent-cloud.com/qcloud/ui/portal-set/build/About/images/bg-product-series_87d.png"))
+ .build()));
+ Prompt prompt = new Prompt(List.of(userMessage),
+ HunYuanChatOptions.builder().model(HunYuanApi.ChatModel.HUNYUAN_TURBO_VISION.getName()).build());
+ ChatResponse response = this.chatModel.call(prompt);
+ assertThat(response.getResults()).hasSize(1);
+ assertThat(response.getResults().get(0).getOutput().getText()).contains("cloud");
+ }
+
+ @Test
+ void cloudPictureStreamTest() throws IOException {
+ UserMessage userMessage = new UserMessage("Which company's logo is in the picture below?", List.of(Media
+ .builder()
+ .mimeType(MimeTypeUtils.IMAGE_PNG)
+ .data(new URL(
+ "https://cloudcache.tencent-cloud.com/qcloud/ui/portal-set/build/About/images/bg-product-series_87d.png"))
+ .build()));
+ Prompt prompt = new Prompt(List.of(userMessage),
+ HunYuanChatOptions.builder().model(HunYuanApi.ChatModel.HUNYUAN_TURBO_VISION.getName()).build());
+ Flux response = this.chatModel.stream(prompt);
+ String content = Objects.requireNonNull(response.collectList().block())
+ .stream()
+ .map(ChatResponse::getResults)
+ .flatMap(List::stream)
+ .map(Generation::getOutput)
+ .map(AssistantMessage::getText)
+ .collect(Collectors.joining());
+ logger.info("Response: {}", content);
+ assertThat(content).contains("cloud");
+ }
+
+ @Test
+ void listOutputConverter() {
+ DefaultConversionService conversionService = new DefaultConversionService();
+ ListOutputConverter outputConverter = new ListOutputConverter(conversionService);
+
+ String format = outputConverter.getFormat();
+ String template = """
+ List five {subject}
+ {format}
+ """;
+ PromptTemplate promptTemplate = new PromptTemplate(template,
+ Map.of("subject", "ice cream flavors", "format", format));
+ 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();
+
+ // TODO investigate why additional text was needed to generate the correct output.
+
+ String format = outputConverter.getFormat();
+ String template = """
+ Provide me a List of {subject}
+ {format}
+ """;
+ PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("subject", """
+ numbers from 1 to 9 under they key name 'numbers'.
+ For example here is a list of numbers from 1 to 3 the required format
+ {
+ "numbers": [1, 2, 3]
+ }""", "format", format));
+ Prompt prompt = new Prompt(promptTemplate.createMessage());
+ Generation generation = this.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.
+ {format}
+ """;
+ PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format));
+ Prompt prompt = new Prompt(promptTemplate.createMessage());
+ Generation generation = this.chatModel.call(prompt).getResult();
+
+ ActorsFilms actorsFilms = outputConverter.convert(generation.getOutput().getText());
+ assertThat(actorsFilms.getActor()).isNotNull();
+ assertThat(actorsFilms.getMovies()).size().isGreaterThan(0);
+ }
+
+ @Test
+ void beanOutputConverterRecords() {
+
+ BeanOutputConverter outputConverter = new BeanOutputConverter<>(ActorsFilmsRecord.class);
+
+ String format = outputConverter.getFormat();
+ String template = """
+ Generate the filmography of 5 movies for Tom Hanks.
+ {format}
+
+ Your response should be without ```json``` and $schema
+ """;
+ PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format));
+ Prompt prompt = new Prompt(promptTemplate.createMessage());
+ Generation generation = this.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.
+ {format}
+
+ your response should be without ```json``` and $shcema.
+ """;
+ PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format));
+ Prompt prompt = new Prompt(promptTemplate.createMessage());
+
+ String generationTextFromStream = this.streamingChatModel.stream(prompt)
+ .collectList()
+ .block()
+ .stream()
+ .map(ChatResponse::getResults)
+ .flatMap(List::stream)
+ .map(Generation::getOutput)
+ .map(AssistantMessage::getText)
+ .collect(Collectors.joining());
+
+ ActorsFilmsRecord actorsFilms = outputConverter.convert(generationTextFromStream);
+ logger.info("" + actorsFilms);
+ assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
+ assertThat(actorsFilms.movies()).hasSize(5);
+ }
+
+ record ActorsFilmsRecord(String actor, List movies) {
+
+ }
+
+}
diff --git a/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/chat/HunYuanChatModelObservationIT.java b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/chat/HunYuanChatModelObservationIT.java
new file mode 100644
index 00000000000..68409ab587e
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/chat/HunYuanChatModelObservationIT.java
@@ -0,0 +1,173 @@
+/*
+ * 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.hunyuan.chat;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+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 reactor.core.publisher.Flux;
+
+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.model.function.DefaultFunctionCallbackResolver;
+import org.springframework.ai.hunyuan.HunYuanChatModel;
+import org.springframework.ai.hunyuan.HunYuanChatOptions;
+import org.springframework.ai.hunyuan.api.HunYuanApi;
+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 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 HunYuanChatModel}.
+ *
+ * @author Guo Junyu
+ */
+@SpringBootTest(classes = HunYuanChatModelObservationIT.Config.class)
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_ID", matches = ".+")
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_KEY", matches = ".+")
+public class HunYuanChatModelObservationIT {
+
+ @Autowired
+ TestObservationRegistry observationRegistry;
+
+ @Autowired
+ HunYuanChatModel chatModel;
+
+ @BeforeEach
+ void beforeEach() {
+ this.observationRegistry.clear();
+ }
+
+ @Test
+ void observationForChatOperation() {
+
+ var options = HunYuanChatOptions.builder()
+ .model(HunYuanApi.ChatModel.HUNYUAN_PRO.getValue())
+ .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 = HunYuanChatOptions.builder()
+ .model(HunYuanApi.ChatModel.HUNYUAN_PRO.getValue())
+ .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 " + HunYuanApi.ChatModel.HUNYUAN_PRO.getValue())
+ .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(),
+ AiOperationType.CHAT.value())
+ .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_PROVIDER.asString(), AiProvider.HUNYUAN.value())
+ .hasLowCardinalityKeyValue(LowCardinalityKeyNames.REQUEST_MODEL.asString(),
+ HunYuanApi.ChatModel.HUNYUAN_PRO.getValue())
+ .hasLowCardinalityKeyValue(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), responseMetadata.getModel())
+ .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().getGenerationTokens()))
+ .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 HunYuanApi hunYuanApi() {
+ return new HunYuanApi(System.getenv("HUNYUAN_SECRET_ID"), System.getenv("HUNYUAN_SECRET_KEY"));
+ }
+
+ @Bean
+ public HunYuanChatModel hunYuanChatModel(HunYuanApi hunYuanApi, TestObservationRegistry observationRegistry) {
+ return new HunYuanChatModel(hunYuanApi, HunYuanChatOptions.builder().build(),
+ new DefaultFunctionCallbackResolver(), List.of(), RetryTemplate.defaultInstance(),
+ observationRegistry);
+ }
+
+ }
+
+}
diff --git a/models/spring-ai-hunyuan/src/test/resources/img.png b/models/spring-ai-hunyuan/src/test/resources/img.png
new file mode 100644
index 00000000000..1ed8bcfa462
Binary files /dev/null and b/models/spring-ai-hunyuan/src/test/resources/img.png differ
diff --git a/models/spring-ai-hunyuan/src/test/resources/prompts/system-message.st b/models/spring-ai-hunyuan/src/test/resources/prompts/system-message.st
new file mode 100644
index 00000000000..579febd8d9b
--- /dev/null
+++ b/models/spring-ai-hunyuan/src/test/resources/prompts/system-message.st
@@ -0,0 +1,3 @@
+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/models/spring-ai-hunyuan/src/test/resources/test.png b/models/spring-ai-hunyuan/src/test/resources/test.png
new file mode 100644
index 00000000000..8abb4c81aea
Binary files /dev/null and b/models/spring-ai-hunyuan/src/test/resources/test.png differ
diff --git a/pom.xml b/pom.xml
index 5d2159e1775..1b305d6259d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -106,6 +106,7 @@
models/spring-ai-watsonx-ai
models/spring-ai-zhipuai
models/spring-ai-moonshot
+ models/spring-ai-hunyuan
spring-ai-spring-boot-starters/spring-ai-starter-anthropic
spring-ai-spring-boot-starters/spring-ai-starter-azure-openai
@@ -126,6 +127,7 @@
spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai
spring-ai-spring-boot-starters/spring-ai-starter-zhipuai
spring-ai-spring-boot-starters/spring-ai-starter-moonshot
+ spring-ai-spring-boot-starters/spring-ai-starter-hunyuan
spring-ai-integration-tests
@@ -178,6 +180,8 @@
1.1.0
4.31.1
1.9.25
+ 2.3.0
+
2.29.29
diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml
index fb8fee83f4e..839946b54ba 100644
--- a/spring-ai-bom/pom.xml
+++ b/spring-ai-bom/pom.xml
@@ -170,6 +170,12 @@
${project.version}
+
+ org.springframework.ai
+ spring-ai-hunyuan
+ ${project.version}
+
+
org.springframework.ai
spring-ai-qianfan
diff --git a/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java
index e723b679b02..43d2141a034 100644
--- a/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java
+++ b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java
@@ -76,6 +76,11 @@ public enum AiProvider {
*/
MOONSHOT("moonshot"),
+ /**
+ * AI system provided by Hunyuan.
+ */
+ HUNYUAN("hunyuan"),
+
/**
* AI system provided by Qianfan.
*/
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc
index d4234afe76e..cad56f7953c 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc
@@ -23,6 +23,8 @@
***** xref:api/chat/functions/vertexai-gemini-chat-functions.adoc[Gemini Function Calling]
*** xref:api/chat/groq-chat.adoc[Groq]
*** xref:api/chat/huggingface.adoc[Hugging Face]
+*** xref:api/chat/hunyuan-chat.adoc[HunYuan AI]
+**** xref:api/chat/functions/hunyuan-chat-functions.adoc[HunYuanFunction Calling]
*** xref:api/chat/mistralai-chat.adoc[Mistral AI]
**** xref:api/chat/functions/mistralai-chat-functions.adoc[Mistral Function Calling]
*** xref:api/chat/minimax-chat.adoc[MiniMax]
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/functions/hunyuan-chat-functions.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/functions/hunyuan-chat-functions.adoc
new file mode 100644
index 00000000000..a655dc82278
--- /dev/null
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/functions/hunyuan-chat-functions.adoc
@@ -0,0 +1,202 @@
+= Function Calling
+
+You can register custom Java functions with the `HunYuanChatModel` and have the HunYuan model intelligently choose to output a JSON object containing arguments to call one or many of the registered functions.
+This allows you to connect the LLM capabilities with external tools and APIs.
+The HunYuan models are trained to detect when a function should be called and to respond with JSON that adheres to the function signature.
+
+The HunYuan API does not call the function directly; instead, the model generates JSON that you can use to call the function in your code and return the result back to the model to complete the conversation.
+
+Spring AI provides flexible and user-friendly ways to register and call custom functions.
+In general, the custom functions need to provide a function `name`, `description`, and the function call `signature` (as JSON schema) to let the model know what arguments the function expects. The `description` helps the model to understand when to call the function.
+
+As a developer, you need to implement a function that takes the function call arguments sent from the AI model, and responds with the result back to the model. Your function can in turn invoke other 3rd party services to provide the results.
+
+Spring AI makes this as easy as defining a `@Bean` definition that returns a `java.util.Function` and supplying the bean name as an option when invoking the `ChatModel`.
+
+Under the hood, Spring wraps your POJO (the function) with the appropriate adapter code that enables interaction with the AI Model, saving you from writing tedious boilerplate code.
+The basis of the underlying infrastructure is the link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/model/function/FunctionCallback.java[FunctionCallback.java] interface and the companion Builder utility class to simplify the implementation and registration of Java callback functions.
+
+// Additionally, the Auto-Configuration provides a way to auto-register any Function beans definition as function calling candidates in the `ChatModel`.
+
+
+== How it works
+
+Suppose we want the AI model to respond with information that it does not have, for example the current temperature at a given location.
+
+We can provide the AI model with metadata about our own functions that it can use to retrieve that information as it processes your prompt.
+
+For example, if during the processing of a prompt, the AI Model determines that it needs additional information about the temperature in a given location, it will start a server side generated request/response interaction. The AI Model invokes a client side function.
+The AI Model provides method invocation details as JSON and it is the responsibility of the client to execute that function and return the response.
+
+The model-client interaction is illustrated in the <> diagram.
+
+Spring AI greatly simplifies code you need to write to support function invocation.
+It brokers the function invocation conversation for you.
+You can simply provide your function definition as a `@Bean` and then provide the bean name of the function in your prompt options.
+You can also reference multiple function bean names in your prompt.
+
+== Quick Start
+
+Let's create a chatbot that answer questions by calling our own function.
+To support the response of the chatbot, we will register our own function that takes a location and returns the current weather in that location.
+
+When the response to the prompt to the model needs to answer a question such as `"What’s the weather like in Boston?"` the AI model will invoke the client providing the location value as an argument to be passed to the function. This RPC-like data is passed as JSON.
+
+Our function calls some SaaS based weather service API and returns the weather response back to the model to complete the conversation. In this example we will use a simple implementation named `MockWeatherService` that hard codes the temperature for various locations.
+
+The following `MockWeatherService.java` represents the weather service API:
+
+[source,java]
+----
+public class MockWeatherService implements Function {
+
+ public enum Unit { C, F }
+ public record Request(String location, Unit unit) {}
+ public record Response(double temp, Unit unit) {}
+
+ public Response apply(Request request) {
+ return new Response(30.0, Unit.C);
+ }
+}
+----
+
+=== Registering Functions as Beans
+
+With the link:../hunyuan-chat.html#_auto_configuration[HunYuanChatModel Auto-Configuration] you have multiple ways to register custom functions as beans in the Spring context.
+
+We start with describing the most POJO friendly options.
+
+
+==== Plain Java Functions
+
+In this approach you define `@Beans` in your application context as you would any other Spring managed object.
+
+Internally, Spring AI `ChatModel` will create an instance of a `FunctionCallback` instance that adds the logic for it being invoked via the AI model.
+The name of the `@Bean` is passed as a `ChatOption`.
+
+
+[source,java]
+----
+@Configuration
+static class Config {
+
+ @Bean
+ @Description("Get the weather in location") // function description
+ public Function weatherFunction1() {
+ return new MockWeatherService();
+ }
+ ...
+}
+----
+
+The `@Description` annotation is optional and provides a function description (2) that helps the model to understand when to call the function. It is an important property to set to help the AI model determine what client side function to invoke.
+
+Another option to provide the description of the function is to the `@JacksonDescription` annotation on the `MockWeatherService.Request` to provide the function description:
+
+[source,java]
+----
+
+@Configuration
+static class Config {
+
+ @Bean
+ public Function currentWeather3() { // (1) bean name as function name.
+ return new MockWeatherService();
+ }
+ ...
+}
+
+@JsonClassDescription("Get the weather in location") // (2) function description
+public record Request(String location, Unit unit) {}
+----
+
+It is a best practice to annotate the request object with information such that the generates JSON schema of that function is as descriptive as possible to help the AI model pick the correct function to invoke.
+
+The link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/tool/FunctionCallbackWithPlainFunctionBeanIT.java[FunctionCallbackWithPlainFunctionBeanIT.java] demonstrates this approach.
+
+
+==== FunctionCallback Wrapper
+
+Another way register a function is to create `FunctionCallback` instance like this:
+
+[source,java]
+----
+@Configuration
+static class Config {
+
+ @Bean
+ public FunctionCallback weatherFunctionInfo() {
+
+ return FunctionCallback.builder()
+ .function("CurrentWeather", new MockWeatherService()) // (1) function name and instance
+ .description("Get the weather in location") // (2) function description
+ .inputType(MockWeatherService.Request.class) // (3) function signature
+ .build();
+ }
+ ...
+}
+----
+
+It wraps the 3rd party, `MockWeatherService` function and registers it as a `CurrentWeather` function with the `HunYuanChatModel`.
+It also provides a description (2) and the function signature (3) to let the model know what arguments the function expects.
+
+NOTE: By default, the response converter does a JSON serialization of the Response object.
+
+NOTE: The `FunctionCallback` internally resolves the function call signature based on the `MockWeatherService.Request` class.
+
+=== Specifying functions in Chat Options
+
+To let the model know and call your `CurrentWeather` function you need to enable it in your prompt requests:
+
+[source,java]
+----
+HunYuanChatModel chatModel = ...
+
+UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?");
+
+ChatResponse response = this.chatModel.call(new Prompt(List.of(this.userMessage),
+ HunYuanChatOptions.builder().function("CurrentWeather").build())); // (1) Enable the function
+
+logger.info("Response: {}", response);
+----
+
+// NOTE: You can can have multiple functions registered in your `ChatModel` but only those enabled in the prompt request will be considered for the function calling.
+
+Above user question will trigger 3 calls to `CurrentWeather` function (one for each city) and the final response will be something like this:
+
+----
+Here is the current weather for the requested cities:
+- San Francisco, CA: 30.0°C
+- Tokyo, Japan: 10.0°C
+- Paris, France: 15.0°C
+----
+
+The link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/tool/HunYuanFunctionCallbackIT.java[HunYuanFunctionCallbackIT.java] test demo this approach.
+
+
+=== Register/Call Functions with Prompt Options
+
+In addition to the auto-configuration you can register callback functions, dynamically, with your Prompt requests:
+
+[source,java]
+----
+HunYuanChatModel chatModel = ...
+
+UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?");
+
+var promptOptions = HunYuanChatOptions.builder()
+ .functionCallbacks(List.of(FunctionCallback.builder()
+ .function("CurrentWeather", new MockWeatherService()) // (1) function name
+ .description("Get the weather in location") // (2) function description
+ .inputType(MockWeatherService.Request.class) // (3) function signature
+ .build())) // function code
+ .build();
+
+ChatResponse response = this.chatModel.call(new Prompt(List.of(this.userMessage), this.promptOptions));
+----
+
+NOTE: The in-prompt registered functions are enabled by default for the duration of this request.
+
+This approach allows to dynamically chose different functions to be called based on the user input.
+
+The https://github.com/spring-projects/spring-ai/blob/main/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/tool/FunctionCallbackInPromptIT.java[FunctionCallbackInPromptIT.java] integration test provides a complete example of how to register a function with the `HunYuanChatModel` and use it in a prompt request.
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/hunyuan-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/hunyuan-chat.adoc
new file mode 100644
index 00000000000..ed42dde8904
--- /dev/null
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/hunyuan-chat.adoc
@@ -0,0 +1,261 @@
+= HunYuan AI Chat
+
+Spring AI supports the various AI language models from HunYuan AI. You can interact with HunYuan AI language models and create a multilingual conversational assistant based on HunYuan models.
+
+== Prerequisites
+
+You will need to create an API with HunYuan to access HunYuan AI language models.
+
+Create an account at https://console.cloud.tencent.com/hunyuan/start[HunYuan AI registration page] and generate the token on the https://console.cloud.tencent.com/cam/capi[API Keys page].
+The Spring AI project defines two configuration properties named `spring.ai.hunyuan.secret-id` and `spring.ai.hunyuan.secret-key` that you should set to the value of the `API Key` obtained from https://console.cloud.tencent.com/cam/capi[API Keys page].
+Exporting an environment variable is one way to set that configuration property:
+
+[source,shell]
+----
+export SPRING_AI_HUNYUAN_SECRET_ID=
+export SPRING_AI_HUNYUAN_SECRET_KEY=
+----
+
+=== Add Repositories and BOM
+
+Spring AI artifacts are published in Spring Milestone and Snapshot repositories.
+Refer to the xref:getting-started.adoc#repositories[Repositories] section to add these repositories to your build system.
+
+To help with dependency management, Spring AI provides a BOM (bill of materials) to ensure that a consistent version of Spring AI is used throughout the entire project. Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build system.
+
+
+
+== Auto-configuration
+
+Spring AI provides Spring Boot auto-configuration for the HunYuan Chat Model.
+To enable it add the following dependency to your project's Maven `pom.xml` file:
+
+[source, xml]
+----
+
+ org.springframework.ai
+ spring-ai-hunyuan-spring-boot-starter
+
+----
+
+or to your Gradle `build.gradle` build file.
+
+[source,groovy]
+----
+dependencies {
+ implementation 'org.springframework.ai:spring-ai-hunyuan-spring-boot-starter'
+}
+----
+
+TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file.
+
+=== Chat Properties
+
+==== Retry Properties
+
+The prefix `spring.ai.retry` is used as the property prefix that lets you configure the retry mechanism for the HunYuan AI Chat model.
+
+[cols="3,5,1", stripes=even]
+|====
+| Property | Description | Default
+
+| spring.ai.retry.max-attempts | Maximum number of retry attempts. | 10
+| spring.ai.retry.backoff.initial-interval | Initial sleep duration for the exponential backoff policy. | 2 sec.
+| spring.ai.retry.backoff.multiplier | Backoff interval multiplier. | 5
+| spring.ai.retry.backoff.max-interval | Maximum backoff duration. | 3 min.
+| spring.ai.retry.on-client-errors | If false, throw a NonTransientAiException, and do not attempt retry for `4xx` client error codes | false
+| spring.ai.retry.exclude-on-http-codes | List of HTTP status codes that should not trigger a retry (e.g. to throw NonTransientAiException). | empty
+| spring.ai.retry.on-http-codes | List of HTTP status codes that should trigger a retry (e.g. to throw TransientAiException). | empty
+|====
+
+==== Connection Properties
+
+The prefix `spring.ai.hunyuan` is used as the property prefix that lets you connect to HunYuan.
+
+[cols="3,5,1", stripes=even]
+|====
+| Property | Description | Default
+
+| spring.ai.hunyuan.base-url | The URL to connect to | https://hunyuan.tencentcloudapi.com
+| spring.ai.hunyuan.secret-id | The API SECRET ID | -
+| spring.ai.hunyuan.secret-key | The API SECRET Key | -
+|====
+
+==== Configuration Properties
+
+The prefix `spring.ai.hunyuan.chat` is the property prefix that lets you configure the chat model implementation for HunYuan.
+
+[cols="3,5,1", stripes=even]
+|====
+| Property | Description | Default
+
+| spring.ai.hunyuan.chat.enabled | Enable HunYuan chat model. | true
+| spring.ai.hunyuan.chat.base-url | Optional overrides the spring.ai.hunyuan.base-url to provide chat specific url | -
+| spring.ai.hunyuan.chat.secret-id | Optional overrides the spring.ai.hunyuan.secret-id to provide chat specific api-secret-id | -
+| spring.ai.hunyuan.chat.secret-key | Optional overrides the spring.ai.hunyuan.secret-key to provide chat specific api-secret-key | -
+| spring.ai.hunyuan.chat.options.model | This is the HunYuan Chat model to use | `hunyuan-pro` (the `hunyuan-lite`, `hunyuan-standard`, `hunyuan-standard-256K`, `hunyuan-pro`, ` hunyuan-code`, `hunyuan-role`, `hunyuan-functioncall`, `hunyuan-vision`, `hunyuan-turbo`, `hunyuan-large`, `hunyuan-large-longcontext`, `hunyuan-turbo-vision`, and ` hunyuan-standard-vision` point to the latest model versions)
+| spring.ai.hunyuan.chat.options.temperature | The sampling temperature to use that controls the apparent creativity of generated completions. Higher values will make output more random while lower values will make results more focused and deterministic. It is not recommended to modify temperature and top_p for the same completions request as the interaction of these two settings is difficult to predict. | 0.7
+| spring.ai.hunyuan.chat.options.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. | 1.0
+| spring.ai.hunyuan.chat.options.enableEnhancement | Enables or disables feature enhancements such as search. This parameter does not affect the security review capability. For hunyuan-lite, this parameter is ineffective. If not specified, the switch is turned on by default. Turning off this switch can reduce response latency, especially for the first character in stream mode, but may slightly degrade the response quality in some scenarios.| true
+| spring.ai.hunyuan.chat.options.stop | Up to 5 sequences where the API will stop generating further tokens. Each string must not exceed 32 bytes | -
+| spring.ai.hunyuan.chat.options.streamModeration | Controls whether the output is reviewed in real-time during streaming. This field is effective only when Stream is set to true. If true, the output is reviewed in real-time, and segments that fail the review will have their FinishReason set to sensitive. If false, the entire output is reviewed before being returned. If real-time text display is required in your application, you should handle the case where FinishReason is sensitive and providing a custom message. | false
+| spring.ai.hunyuan.chat.options.searchInfo | If true, the interface will return SearchInfo when a search hit occurs. | false
+| spring.ai.hunyuan.chat.options.citation | Enables or disables citation markers in the response. This parameter works in conjunction with EnableEnhancement and SearchInfo. If true, search results in the response will be marked with a citation marker corresponding to links in the SearchInfo list. If not specified, the default is false. | false
+| spring.ai.hunyuan.chat.options.enableSpeedSearch | Enables or disables the fast version of search. If true and a search hit occurs, the fast version of search will be used, which can reduce the latency of the first character in the stream. | false
+| spring.ai.hunyuan.chat.options.enableMultimedia | Enables or disables multimedia capabilities. This parameter is effective only for whitelisted users and when EnableEnhancement is true and EnableSpeedSearch is false. For hunyuan-lite, this parameter is ineffective. If not specified, the default is false. When enabled and a multimedia hit occurs, the corresponding multimedia address will be output. | false
+| spring.ai.hunyuan.chat.options.enableDeepSearch | Enables or disables deep research on the question. If true and a deep research hit occurs, information about the deep research will be returned. | false
+| spring.ai.hunyuan.chat.options.enableSpeedSearch | Enables or disables the fast version of search. If true and a search hit occurs, the fast version of search will be used, which can reduce the latency of the first character in the stream. | false
+| spring.ai.hunyuan.chat.options.seed | Ensures the model's output is reproducible. The value should be a non-zero positive integer, with a maximum value of 10000. It is not recommended to use this parameter unless necessary, as improper values can affect the output quality. | 1
+| spring.ai.hunyuan.chat.options.forceSearchEnhancement | Forces the use of AI search. If true, AI search will be used, and if the AI search result is empty, the large model will provide a fallback response. | false
+| spring.ai.hunyuan.chat.options.enableRecommendedQuestions | Enables or disables the recommendation of additional questions. If true, the response will include a RecommendedQuestions field with up to 3 recommended questions in the last package. | false
+|====
+
+NOTE: You can override the common `spring.ai.hunyuan.base-url` and `spring.ai.hunyuan.secret-id` and `spring.ai.hunyuan.secret-key` for the `ChatModel` implementations.
+The `spring.ai.hunyuan.chat.base-url` and `spring.ai.hunyuan.chat.secret-id` and `spring.ai.hunyuan.chat.secret-key` properties if set take precedence over the common properties.
+This is useful if you want to use different HunYuan accounts for different models and different model endpoints.
+
+TIP: All properties prefixed with `spring.ai.hunyuan.chat.options` can be overridden at runtime by adding a request specific <> to the `Prompt` call.
+
+== Runtime Options [[chat-options]]
+
+The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/HunYuanChatOptions.java[HunYuanChatOptions.java] provides model configurations, such as the model to use, the temperature, the frequency penalty, etc.
+
+On start-up, the default options can be configured with the `HunYuanChatModel(api, options)` constructor or the `spring.ai.hunyuan.chat.options.*` properties.
+
+At run-time you can override the default options by adding new, request specific, options to the `Prompt` call.
+For example to override the default model and temperature for a specific request:
+
+[source,java]
+----
+ChatResponse response = chatModel.call(
+ new Prompt(
+ "Generate the names of 5 famous pirates.",
+ HunYuanChatOptions.builder()
+ .model(HunYuanApi.ChatModel.HUNYUAN_PRO.getValue())
+ .temperature(0.5)
+ .build()
+ ));
+----
+
+TIP: In addition to the model specific link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/HunYuanChatOptions.java[HunYuanChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()].
+
+== Sample Controller (Auto-configuration)
+
+https://start.spring.io/[Create] a new Spring Boot project and add the `spring-ai-hunyuan-spring-boot-starter` to your pom (or gradle) dependencies.
+
+Add a `application.properties` file, under the `src/main/resources` directory, to enable and configure the HunYuan Chat model:
+
+[source,application.properties]
+----
+spring.ai.hunyuan.secret-id=YOUR_API_SECRET_ID
+spring.ai.hunyuan.secret-key=YOUR_API_SECRET_KEY
+spring.ai.hunyuan.chat.options.model=hunyuan-pro
+spring.ai.hunyuan.chat.options.temperature=0.7
+----
+
+TIP: replace the `secret-id` and `secret-key` with your HunYuan credentials.
+
+This will create a `HunYuanChatModel` implementation that you can inject into your class.
+Here is an example of a simple `@Controller` class that uses the chat model for text generations.
+
+[source,java]
+----
+@RestController
+public class ChatController {
+
+ private final HunYuanChatModel chatModel;
+
+ @Autowired
+ public ChatController(HunYuanChatModel chatModel) {
+ this.chatModel = chatModel;
+ }
+
+ @GetMapping("/ai/generate")
+ public Map generate(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
+ return Map.of("generation", this.chatModel.call(message));
+ }
+
+ @GetMapping("/ai/generateStream")
+ public Flux generateStream(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
+ var prompt = new Prompt(new UserMessage(message));
+ return this.chatModel.stream(prompt);
+ }
+}
+----
+
+== Manual Configuration
+
+The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/HunYuanChatModel.java[HunYuanChatModel] implements the `ChatModel` and `StreamingChatModel` and uses the <> to connect to the HunYuan service.
+
+Add the `spring-ai-hunyuan` dependency to your project's Maven `pom.xml` file:
+
+[source, xml]
+----
+
+ org.springframework.ai
+ spring-ai-hunyuan
+
+----
+
+or to your Gradle `build.gradle` build file.
+
+[source,groovy]
+----
+dependencies {
+ implementation 'org.springframework.ai:spring-ai-hunyuan'
+}
+----
+
+TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file.
+
+Next, create a `HunYuanChatModel` and use it for text generations:
+
+[source,java]
+----
+var hunyuanApi = new HunYuanApi(System.getenv("HUNYUAN_SECRET_ID"),System.getenv("HUNYUAN_SECRET_KEY"));
+
+var chatModel = new HunYuanChatModel(this.hunyuanApi, HunYuanChatOptions.builder()
+ .model(HunYuanApi.ChatModel.HUNYUAN_PRO.getValue())
+ .temperature(0.4)
+ .maxTokens(200)
+ .build());
+
+ChatResponse response = this.chatModel.call(
+ new Prompt("Generate the names of 5 famous pirates."));
+
+// Or with streaming responses
+Flux streamResponse = this.chatModel.stream(
+ new Prompt("Generate the names of 5 famous pirates."));
+----
+
+The `HunYuanChatOptions` provides the configuration information for the chat requests.
+The `HunYuanChatOptions.Builder` is fluent options builder.
+
+=== Low-level HunYuan Api Client [[low-level-api]]
+
+The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/api/HunYuanApi.java[HunYuanApi] provides is lightweight Java client for link:https://cloud.tencent.com/document/product/1729/101848[HunYuan AI API].
+
+Here is a simple snippet how to use the api programmatically:
+
+[source,java]
+----
+HunYuanApi hunyuanApi = new HunYuanApi(System.getenv("HUNYUAN_SECRET_ID"),System.getenv("HUNYUAN_SECRET_KEY"));
+
+ChatCompletionMessage chatCompletionMessage =
+ new ChatCompletionMessage("Hello world", Role.USER);
+
+// Sync request
+ResponseEntity response = this.hunyuanApi.chatCompletionEntity(
+ new ChatCompletionRequest(List.of(this.chatCompletionMessage), HunYuanApi.ChatModel.HUNYUAN_PRO.getValue(), 0.7, false));
+
+// Streaming request
+Flux streamResponse = this.hunyuanApi.chatCompletionStream(
+ new ChatCompletionRequest(List.of(this.chatCompletionMessage), HunYuanApi.ChatModel.HUNYUAN_PRO.getValue(), 0.7, true));
+----
+
+Follow the https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-hunyuan/src/main/java/org/springframework/ai/hunyuan/api/HunYuanApi.java[HunYuanApi.java]'s JavaDoc for further information.
+
+==== HunYuanApi Samples
+* The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/api/HunYuanApiIT.java[HunYuanApiIT.java] test provides some general examples how to use the lightweight library.
+
+* The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-hunyuan/src/test/java/org/springframework/ai/hunyuan/api/HunYuanApiToolFunctionCallIT.java[HunYuanApiToolFunctionCallIT.java] test shows how to use the low-level API to call tool functions.
\ No newline at end of file
diff --git a/spring-ai-spring-boot-autoconfigure/pom.xml b/spring-ai-spring-boot-autoconfigure/pom.xml
index 92ac01362c6..f7346dc4e33 100644
--- a/spring-ai-spring-boot-autoconfigure/pom.xml
+++ b/spring-ai-spring-boot-autoconfigure/pom.xml
@@ -374,6 +374,14 @@
true
+
+
+ org.springframework.ai
+ spring-ai-hunyuan
+ ${project.parent.version}
+ true
+
+
org.springframework.ai
spring-ai-qianfan
diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/hunyuan/HunYuanAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/hunyuan/HunYuanAutoConfiguration.java
new file mode 100644
index 00000000000..03ec99821aa
--- /dev/null
+++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/hunyuan/HunYuanAutoConfiguration.java
@@ -0,0 +1,101 @@
+/*
+ * 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.autoconfigure.hunyuan;
+
+import io.micrometer.observation.ObservationRegistry;
+import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration;
+import org.springframework.ai.chat.observation.ChatModelObservationConvention;
+import org.springframework.ai.model.function.DefaultFunctionCallbackResolver;
+import org.springframework.ai.model.function.FunctionCallback;
+import org.springframework.ai.model.function.FunctionCallbackResolver;
+import org.springframework.ai.hunyuan.HunYuanChatModel;
+import org.springframework.ai.hunyuan.api.HunYuanApi;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.retry.support.RetryTemplate;
+import org.springframework.util.Assert;
+import org.springframework.util.StringUtils;
+import org.springframework.web.client.ResponseErrorHandler;
+import org.springframework.web.client.RestClient;
+
+import java.util.List;
+
+/**
+ * {@link AutoConfiguration Auto-configuration} for HunYuan Chat Model.
+ *
+ * @author Guo Junyu
+ */
+@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class })
+@EnableConfigurationProperties({ HunYuanCommonProperties.class, HunYuanChatProperties.class })
+@ConditionalOnClass(HunYuanApi.class)
+public class HunYuanAutoConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnProperty(prefix = HunYuanChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true",
+ matchIfMissing = true)
+ public HunYuanChatModel hunyuanChatModel(HunYuanCommonProperties commonProperties,
+ HunYuanChatProperties chatProperties, ObjectProvider restClientBuilderProvider,
+ List toolFunctionCallbacks, FunctionCallbackResolver functionCallbackResolver,
+ RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler,
+ ObjectProvider observationRegistry,
+ ObjectProvider observationConvention) {
+
+ var hunyuanApi = hunyuanApi(chatProperties.getSecretId(), commonProperties.getSecretId(),
+ chatProperties.getSecretKey(), commonProperties.getSecretKey(), chatProperties.getBaseUrl(),
+ commonProperties.getBaseUrl(), restClientBuilderProvider.getIfAvailable(RestClient::builder),
+ responseErrorHandler);
+
+ var chatModel = new HunYuanChatModel(hunyuanApi, chatProperties.getOptions(), functionCallbackResolver,
+ toolFunctionCallbacks, retryTemplate, observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP));
+
+ observationConvention.ifAvailable(chatModel::setObservationConvention);
+ return chatModel;
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ public FunctionCallbackResolver springAiFunctionManager(ApplicationContext context) {
+ DefaultFunctionCallbackResolver manager = new DefaultFunctionCallbackResolver();
+ manager.setApplicationContext(context);
+ return manager;
+ }
+
+ private HunYuanApi hunyuanApi(String secretId, String commonSecretId, String secretKey, String commonSecretKey,
+ String baseUrl, String commonBaseUrl, RestClient.Builder restClientBuilder,
+ ResponseErrorHandler responseErrorHandler) {
+
+ var resolvedSecretId = StringUtils.hasText(secretId) ? secretId : commonSecretId;
+ var resolvedSecretKey = StringUtils.hasText(secretKey) ? secretKey : commonSecretKey;
+ var resoledBaseUrl = StringUtils.hasText(baseUrl) ? baseUrl : commonBaseUrl;
+
+ Assert.hasText(resolvedSecretId, "HunYuan SecretId must be set");
+ Assert.hasText(resolvedSecretKey, "HunYuan SecretKey must be set");
+ Assert.hasText(resoledBaseUrl, "HunYuan base URL must be set");
+
+ return new HunYuanApi(resoledBaseUrl, resolvedSecretId, resolvedSecretKey, restClientBuilder,
+ responseErrorHandler);
+ }
+
+}
diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/hunyuan/HunYuanChatProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/hunyuan/HunYuanChatProperties.java
new file mode 100644
index 00000000000..412ece2b4ad
--- /dev/null
+++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/hunyuan/HunYuanChatProperties.java
@@ -0,0 +1,65 @@
+/*
+ * 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.autoconfigure.hunyuan;
+
+import org.springframework.ai.hunyuan.HunYuanChatOptions;
+import org.springframework.ai.hunyuan.api.HunYuanApi;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.NestedConfigurationProperty;
+
+/**
+ * Configuration properties for HunYuan chat client.
+ *
+ * @author Guo Junyu
+ */
+@ConfigurationProperties(HunYuanChatProperties.CONFIG_PREFIX)
+public class HunYuanChatProperties extends HunYuanParentProperties {
+
+ public static final String CONFIG_PREFIX = "spring.ai.hunyuan.chat";
+
+ public static final String DEFAULT_CHAT_MODEL = HunYuanApi.ChatModel.HUNYUAN_PRO.getValue();
+
+ private static final Double DEFAULT_TEMPERATURE = 0.7;
+
+ /**
+ * Enable HunYuan chat client.
+ */
+ private boolean enabled = true;
+
+ @NestedConfigurationProperty
+ private HunYuanChatOptions options = HunYuanChatOptions.builder()
+ .model(DEFAULT_CHAT_MODEL)
+ .temperature(DEFAULT_TEMPERATURE)
+ .build();
+
+ public HunYuanChatOptions getOptions() {
+ return this.options;
+ }
+
+ public void setOptions(HunYuanChatOptions options) {
+ this.options = options;
+ }
+
+ public boolean isEnabled() {
+ return this.enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+}
diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/hunyuan/HunYuanCommonProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/hunyuan/HunYuanCommonProperties.java
new file mode 100644
index 00000000000..fc1a90dd9a4
--- /dev/null
+++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/hunyuan/HunYuanCommonProperties.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.autoconfigure.hunyuan;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Parent properties for HunYuan.
+ *
+ * @author Guo Junyu
+ */
+@ConfigurationProperties(HunYuanCommonProperties.CONFIG_PREFIX)
+public class HunYuanCommonProperties extends HunYuanParentProperties {
+
+ public static final String CONFIG_PREFIX = "spring.ai.hunyuan";
+
+ public static final String DEFAULT_BASE_URL = "https://hunyuan.tencentcloudapi.com";
+
+ public HunYuanCommonProperties() {
+ super.setBaseUrl(DEFAULT_BASE_URL);
+ }
+
+}
diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/hunyuan/HunYuanParentProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/hunyuan/HunYuanParentProperties.java
new file mode 100644
index 00000000000..29a6b140a40
--- /dev/null
+++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/hunyuan/HunYuanParentProperties.java
@@ -0,0 +1,56 @@
+/*
+ * 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.autoconfigure.hunyuan;
+
+/**
+ * Parent properties for HunYuan.
+ *
+ * @author Guo Junyu
+ */
+public class HunYuanParentProperties {
+
+ private String secretId;
+
+ private String secretKey;
+
+ private String baseUrl;
+
+ public String getSecretKey() {
+ return secretKey;
+ }
+
+ public void setSecretKey(String secretKey) {
+ this.secretKey = secretKey;
+ }
+
+ public String getSecretId() {
+ return this.secretId;
+ }
+
+ public void setSecretId(String secretId) {
+ this.secretId = secretId;
+ }
+
+ public String getBaseUrl() {
+ return this.baseUrl;
+ }
+
+ public void setBaseUrl(String baseUrl) {
+ this.baseUrl = baseUrl;
+ }
+
+}
diff --git a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index f3e5633efc0..0705bc75515 100644
--- a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -66,3 +66,4 @@ org.springframework.ai.autoconfigure.minimax.MiniMaxAutoConfiguration
org.springframework.ai.autoconfigure.vertexai.embedding.VertexAiEmbeddingAutoConfiguration
org.springframework.ai.autoconfigure.chat.memory.cassandra.CassandraChatMemoryAutoConfiguration
org.springframework.ai.autoconfigure.vectorstore.observation.VectorStoreObservationAutoConfiguration
+org.springframework.ai.autoconfigure.hunyuan.HunYuanAutoConfiguration
\ No newline at end of file
diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/HunYuanAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/HunYuanAutoConfigurationIT.java
new file mode 100644
index 00000000000..533b219298b
--- /dev/null
+++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/HunYuanAutoConfigurationIT.java
@@ -0,0 +1,78 @@
+/*
+ * 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.autoconfigure.hunyuan;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration;
+import org.springframework.ai.chat.messages.UserMessage;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.hunyuan.HunYuanChatModel;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import reactor.core.publisher.Flux;
+
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Guo Junyu
+ */
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_ID", matches = ".+")
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_KEY", matches = ".+")
+public class HunYuanAutoConfigurationIT {
+
+ private static final Log logger = LogFactory.getLog(HunYuanAutoConfigurationIT.class);
+
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withPropertyValues("spring.ai.hunyuan.secret-id=" + System.getenv("HUNYUAN_SECRET_ID"))
+ .withPropertyValues("spring.ai.hunyuan.secret-key=" + System.getenv("HUNYUAN_SECRET_KEY"))
+ .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
+ RestClientAutoConfiguration.class, HunYuanAutoConfiguration.class));
+
+ @Test
+ void generate() {
+ this.contextRunner.run(context -> {
+ HunYuanChatModel client = context.getBean(HunYuanChatModel.class);
+ String response = client.call("Hello");
+ assertThat(response).isNotEmpty();
+ logger.info("Response: " + response);
+ });
+ }
+
+ @Test
+ void generateStreaming() {
+ this.contextRunner.run(context -> {
+ HunYuanChatModel client = context.getBean(HunYuanChatModel.class);
+ Flux responseFlux = client.stream(new Prompt(new UserMessage("Hello")));
+ String response = Objects.requireNonNull(responseFlux.collectList().block())
+ .stream()
+ .map(chatResponse -> chatResponse.getResults().get(0).getOutput().getText())
+ .collect(Collectors.joining());
+
+ assertThat(response).isNotEmpty();
+ logger.info("Response: " + response);
+ });
+ }
+
+}
diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/HunYuanPropertiesTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/HunYuanPropertiesTests.java
new file mode 100644
index 00000000000..401f32a2ca0
--- /dev/null
+++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/HunYuanPropertiesTests.java
@@ -0,0 +1,164 @@
+/*
+ * 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.autoconfigure.hunyuan;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration;
+import org.springframework.ai.hunyuan.HunYuanChatModel;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Guo Junyu
+ */
+public class HunYuanPropertiesTests {
+
+ @Test
+ public void chatProperties() {
+
+ new ApplicationContextRunner().withPropertyValues(
+ // @formatter:off
+ "spring.ai.hunyuan.base-url=TEST_BASE_URL",
+ "spring.ai.hunyuan.secret-id=abc123",
+ "spring.ai.hunyuan.secret-key=zaq123",
+ "spring.ai.hunyuan.chat.options.model=MODEL_XYZ",
+ "spring.ai.hunyuan.chat.options.temperature=0.55")
+ // @formatter:on
+ .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
+ RestClientAutoConfiguration.class, HunYuanAutoConfiguration.class))
+ .run(context -> {
+ var chatProperties = context.getBean(HunYuanChatProperties.class);
+ var connectionProperties = context.getBean(HunYuanCommonProperties.class);
+
+ assertThat(connectionProperties.getSecretId()).isEqualTo("abc123");
+ assertThat(connectionProperties.getSecretKey()).isEqualTo("zaq123");
+ assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
+
+ assertThat(chatProperties.getSecretId()).isNull();
+ assertThat(chatProperties.getSecretKey()).isNull();
+ assertThat(chatProperties.getBaseUrl()).isNull();
+
+ assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
+ assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55);
+ });
+ }
+
+ @Test
+ public void chatOverrideConnectionProperties() {
+
+ new ApplicationContextRunner().withPropertyValues(
+ // @formatter:off
+ "spring.ai.hunyuan.base-url=TEST_BASE_URL",
+ "spring.ai.hunyuan.secret-id=abc123",
+ "spring.ai.hunyuan.secret-key=zaq123",
+ "spring.ai.hunyuan.chat.base-url=TEST_BASE_URL2",
+ "spring.ai.hunyuan.chat.secret-id=456",
+ "spring.ai.hunyuan.chat.secret-key=789",
+ "spring.ai.hunyuan.chat.options.model=MODEL_XYZ",
+ "spring.ai.hunyuan.chat.options.temperature=0.55")
+ // @formatter:on
+ .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
+ RestClientAutoConfiguration.class, HunYuanAutoConfiguration.class))
+ .run(context -> {
+ var chatProperties = context.getBean(HunYuanChatProperties.class);
+ var connectionProperties = context.getBean(HunYuanCommonProperties.class);
+
+ assertThat(connectionProperties.getSecretId()).isEqualTo("abc123");
+ assertThat(connectionProperties.getSecretKey()).isEqualTo("zaq123");
+ assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
+
+ assertThat(chatProperties.getSecretId()).isEqualTo("456");
+ assertThat(chatProperties.getSecretKey()).isEqualTo("789");
+ assertThat(chatProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2");
+
+ assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
+ assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55);
+ });
+ }
+
+ @Test
+ public void chatOptionsTest() {
+
+ new ApplicationContextRunner().withPropertyValues(
+ // @formatter:off
+ "spring.ai.hunyuan.secret-id=API_ID",
+ "spring.ai.hunyuan.secret-key=API_KEY",
+ "spring.ai.hunyuan.base-url=TEST_BASE_URL",
+
+ "spring.ai.hunyuan.chat.options.model=MODEL_XYZ",
+ "spring.ai.hunyuan.chat.options.n=10",
+ "spring.ai.hunyuan.chat.options.responseFormat.type=json",
+ "spring.ai.hunyuan.chat.options.seed=66",
+ "spring.ai.hunyuan.chat.options.stop=boza,koza",
+ "spring.ai.hunyuan.chat.options.temperature=0.55",
+ "spring.ai.hunyuan.chat.options.topP=0.56"
+ )
+ // @formatter:on
+ .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
+ RestClientAutoConfiguration.class, HunYuanAutoConfiguration.class))
+ .run(context -> {
+ var chatProperties = context.getBean(HunYuanChatProperties.class);
+ var connectionProperties = context.getBean(HunYuanCommonProperties.class);
+
+ assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
+ assertThat(connectionProperties.getSecretId()).isEqualTo("API_ID");
+ assertThat(connectionProperties.getSecretKey()).isEqualTo("API_KEY");
+
+ assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
+ assertThat(chatProperties.getOptions().getStop()).contains("boza", "koza");
+ assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55);
+ assertThat(chatProperties.getOptions().getTopP()).isEqualTo(0.56);
+ });
+ }
+
+ @Test
+ void chatActivation() {
+ new ApplicationContextRunner()
+ .withPropertyValues("spring.ai.hunyuan.secret-id=API_ID", "spring.ai.hunyuan.secret-key=API_KEY",
+ "spring.ai.hunyuan.base-url=TEST_BASE_URL", "spring.ai.hunyuan.chat.enabled=false")
+ .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
+ RestClientAutoConfiguration.class, HunYuanAutoConfiguration.class))
+ .run(context -> {
+ assertThat(context.getBeansOfType(HunYuanChatProperties.class)).isNotEmpty();
+ assertThat(context.getBeansOfType(HunYuanChatModel.class)).isEmpty();
+ });
+
+ new ApplicationContextRunner()
+ .withPropertyValues("spring.ai.hunyuan.secret-id=API_ID", "spring.ai.hunyuan.secret-key=API_KEY",
+ "spring.ai.hunyuan.base-url=TEST_BASE_URL")
+ .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
+ RestClientAutoConfiguration.class, HunYuanAutoConfiguration.class))
+ .run(context -> {
+ assertThat(context.getBeansOfType(HunYuanChatProperties.class)).isNotEmpty();
+ assertThat(context.getBeansOfType(HunYuanChatModel.class)).isNotEmpty();
+ });
+
+ new ApplicationContextRunner()
+ .withPropertyValues("spring.ai.hunyuan.secret-id=API_ID", "spring.ai.hunyuan.secret-key=API_KEY",
+ "spring.ai.hunyuan.base-url=TEST_BASE_URL", "spring.ai.hunyuan.chat.enabled=true")
+ .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
+ RestClientAutoConfiguration.class, HunYuanAutoConfiguration.class))
+ .run(context -> {
+ assertThat(context.getBeansOfType(HunYuanChatProperties.class)).isNotEmpty();
+ assertThat(context.getBeansOfType(HunYuanChatModel.class)).isNotEmpty();
+ });
+ }
+
+}
diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/tool/FunctionCallbackInPromptIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/tool/FunctionCallbackInPromptIT.java
new file mode 100644
index 00000000000..80e4a4a7b92
--- /dev/null
+++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/tool/FunctionCallbackInPromptIT.java
@@ -0,0 +1,119 @@
+/*
+ * 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.autoconfigure.hunyuan.tool;
+
+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.autoconfigure.hunyuan.HunYuanAutoConfiguration;
+import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.messages.UserMessage;
+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.hunyuan.HunYuanChatModel;
+import org.springframework.ai.hunyuan.HunYuanChatOptions;
+import org.springframework.ai.model.function.FunctionCallback;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import reactor.core.publisher.Flux;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Guo Junyu
+ */
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_ID", matches = ".+")
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_KEY", matches = ".+")
+public class FunctionCallbackInPromptIT {
+
+ private final Logger logger = LoggerFactory.getLogger(FunctionCallbackInPromptIT.class);
+
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withPropertyValues("spring.ai.hunyuan.secret-id=" + System.getenv("HUNYUAN_SECRET_ID"))
+ .withPropertyValues("spring.ai.hunyuan.secret-key=" + System.getenv("HUNYUAN_SECRET_KEY"))
+ .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
+ RestClientAutoConfiguration.class, HunYuanAutoConfiguration.class));
+
+ @Test
+ void functionCallTest() {
+ this.contextRunner.run(context -> {
+
+ HunYuanChatModel chatModel = context.getBean(HunYuanChatModel.class);
+
+ UserMessage userMessage = new UserMessage(
+ "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius");
+
+ var promptOptions = HunYuanChatOptions.builder()
+ .functionCallbacks(List.of(FunctionCallback.builder()
+ .function("CurrentWeatherService", new MockWeatherService())
+ .description("Get the weather in location")
+ .inputType(MockWeatherService.Request.class)
+ .build()))
+ .build();
+
+ ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), promptOptions));
+
+ logger.info("Response: {}", response);
+
+ assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15");
+ });
+ }
+
+ @Test
+ void streamingFunctionCallTest() {
+
+ this.contextRunner.run(context -> {
+
+ HunYuanChatModel chatModel = context.getBean(HunYuanChatModel.class);
+
+ UserMessage userMessage = new UserMessage(
+ "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius");
+
+ var promptOptions = HunYuanChatOptions.builder()
+ .functionCallbacks(List.of(FunctionCallback.builder()
+ .function("CurrentWeatherService", new MockWeatherService())
+ .description("Get the weather in location")
+ .inputType(MockWeatherService.Request.class)
+ .build()))
+ .build();
+
+ Flux response = chatModel.stream(new Prompt(List.of(userMessage), promptOptions));
+
+ String content = response.collectList()
+ .block()
+ .stream()
+ .map(ChatResponse::getResults)
+ .flatMap(List::stream)
+ .map(Generation::getOutput)
+ .map(AssistantMessage::getText)
+ .collect(Collectors.joining());
+ logger.info("Response: {}", content);
+
+ assertThat(content).containsAnyOf("30.0", "30");
+ assertThat(content).containsAnyOf("10.0", "10");
+ assertThat(content).containsAnyOf("15.0", "15");
+ });
+ }
+
+}
diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/tool/FunctionCallbackWithPlainFunctionBeanIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/tool/FunctionCallbackWithPlainFunctionBeanIT.java
new file mode 100644
index 00000000000..17a4a24789e
--- /dev/null
+++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/tool/FunctionCallbackWithPlainFunctionBeanIT.java
@@ -0,0 +1,177 @@
+/*
+ * 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.autoconfigure.hunyuan.tool;
+
+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.autoconfigure.hunyuan.HunYuanAutoConfiguration;
+import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.messages.UserMessage;
+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.hunyuan.HunYuanChatModel;
+import org.springframework.ai.hunyuan.HunYuanChatOptions;
+import org.springframework.ai.model.function.FunctionCallingOptions;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Description;
+import reactor.core.publisher.Flux;
+
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Guo Junyu
+ */
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_ID", matches = ".+")
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_KEY", matches = ".+")
+class FunctionCallbackWithPlainFunctionBeanIT {
+
+ private final Logger logger = LoggerFactory.getLogger(FunctionCallbackWithPlainFunctionBeanIT.class);
+
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withPropertyValues("spring.ai.hunyuan.secret-id=" + System.getenv("HUNYUAN_SECRET_ID"))
+ .withPropertyValues("spring.ai.hunyuan.secret-key=" + System.getenv("HUNYUAN_SECRET_KEY"))
+ .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
+ RestClientAutoConfiguration.class, HunYuanAutoConfiguration.class))
+ .withUserConfiguration(Config.class);
+
+ @Test
+ void functionCallTest() {
+ this.contextRunner.run(context -> {
+
+ HunYuanChatModel chatModel = context.getBean(HunYuanChatModel.class);
+
+ // Test weatherFunction
+ UserMessage userMessage = new UserMessage(
+ "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius");
+
+ ChatResponse response = chatModel.call(
+ new Prompt(List.of(userMessage), HunYuanChatOptions.builder().function("weatherFunction").build()));
+
+ logger.info("Response: {}", response);
+
+ assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15");
+
+ // Test weatherFunctionTwo
+ response = chatModel.call(new Prompt(List.of(userMessage),
+ HunYuanChatOptions.builder().function("weatherFunctionTwo").build()));
+
+ logger.info("Response: {}", response);
+
+ assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15");
+
+ });
+ }
+
+ @Test
+ void functionCallWithPortableFunctionCallingOptions() {
+ this.contextRunner.run(context -> {
+
+ HunYuanChatModel chatModel = context.getBean(HunYuanChatModel.class);
+
+ // Test weatherFunction
+ UserMessage userMessage = new UserMessage(
+ "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius");
+
+ FunctionCallingOptions functionOptions = FunctionCallingOptions.builder()
+ .function("weatherFunction")
+ .build();
+
+ ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), functionOptions));
+
+ logger.info("Response: {}", response);
+ });
+ }
+
+ @Test
+ void streamFunctionCallTest() {
+ this.contextRunner.run(context -> {
+
+ HunYuanChatModel chatModel = context.getBean(HunYuanChatModel.class);
+
+ // Test weatherFunction
+ UserMessage userMessage = new UserMessage(
+ "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius");
+
+ Flux response = chatModel.stream(
+ new Prompt(List.of(userMessage), HunYuanChatOptions.builder().function("weatherFunction").build()));
+
+ String content = response.collectList()
+ .block()
+ .stream()
+ .map(ChatResponse::getResults)
+ .flatMap(List::stream)
+ .map(Generation::getOutput)
+ .map(AssistantMessage::getText)
+ .collect(Collectors.joining());
+ logger.info("Response: {}", content);
+
+ assertThat(content).containsAnyOf("30.0", "30");
+ assertThat(content).containsAnyOf("10.0", "10");
+ assertThat(content).containsAnyOf("15.0", "15");
+
+ // Test weatherFunctionTwo
+ response = chatModel.stream(new Prompt(List.of(userMessage),
+ HunYuanChatOptions.builder().function("weatherFunctionTwo").build()));
+
+ content = response.collectList()
+ .block()
+ .stream()
+ .map(ChatResponse::getResults)
+ .flatMap(List::stream)
+ .map(Generation::getOutput)
+ .map(AssistantMessage::getText)
+ .collect(Collectors.joining());
+ logger.info("Response: {}", content);
+
+ assertThat(content).containsAnyOf("30.0", "30");
+ assertThat(content).containsAnyOf("10.0", "10");
+ assertThat(content).containsAnyOf("15.0", "15");
+ });
+ }
+
+ @Configuration
+ static class Config {
+
+ @Bean
+ @Description("Get the weather in location")
+ public Function weatherFunction() {
+ return new MockWeatherService();
+ }
+
+ // Relies on the Request's JsonClassDescription annotation to provide the
+ // function description.
+ @Bean
+ public Function weatherFunctionTwo() {
+ MockWeatherService weatherService = new MockWeatherService();
+ return (weatherService::apply);
+ }
+
+ }
+
+}
diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/tool/HunYuanFunctionCallbackIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/tool/HunYuanFunctionCallbackIT.java
new file mode 100644
index 00000000000..5a88052ce8c
--- /dev/null
+++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/tool/HunYuanFunctionCallbackIT.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.autoconfigure.hunyuan.tool;
+
+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.autoconfigure.hunyuan.HunYuanAutoConfiguration;
+import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.messages.UserMessage;
+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.hunyuan.HunYuanChatModel;
+import org.springframework.ai.hunyuan.HunYuanChatOptions;
+import org.springframework.ai.model.function.FunctionCallback;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import reactor.core.publisher.Flux;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Guo Junyu
+ */
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_ID", matches = ".+")
+@EnabledIfEnvironmentVariable(named = "HUNYUAN_SECRET_KEY", matches = ".+")
+public class HunYuanFunctionCallbackIT {
+
+ private final Logger logger = LoggerFactory.getLogger(HunYuanFunctionCallbackIT.class);
+
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withPropertyValues("spring.ai.hunyuan.secret-id=" + System.getenv("HUNYUAN_SECRET_ID"))
+ .withPropertyValues("spring.ai.hunyuan.secret-key=" + System.getenv("HUNYUAN_SECRET_KEY"))
+ .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
+ RestClientAutoConfiguration.class, HunYuanAutoConfiguration.class))
+ .withUserConfiguration(Config.class);
+
+ @Test
+ void functionCallTest() {
+ this.contextRunner.run(context -> {
+
+ HunYuanChatModel chatModel = context.getBean(HunYuanChatModel.class);
+
+ UserMessage userMessage = new UserMessage(
+ "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius");
+
+ ChatResponse response = chatModel
+ .call(new Prompt(List.of(userMessage), HunYuanChatOptions.builder().function("WeatherInfo").build()));
+
+ logger.info("Response: {}", response);
+
+ assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15");
+
+ });
+ }
+
+ @Test
+ void streamFunctionCallTest() {
+ this.contextRunner.run(context -> {
+
+ HunYuanChatModel chatModel = context.getBean(HunYuanChatModel.class);
+
+ UserMessage userMessage = new UserMessage(
+ "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius");
+
+ Flux response = chatModel
+ .stream(new Prompt(List.of(userMessage), HunYuanChatOptions.builder().function("WeatherInfo").build()));
+
+ 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).containsAnyOf("30.0", "30");
+ assertThat(content).containsAnyOf("10.0", "10");
+ assertThat(content).containsAnyOf("15.0", "15");
+
+ });
+ }
+
+ @Configuration
+ static class Config {
+
+ @Bean
+ public FunctionCallback weatherFunctionInfo() {
+
+ return FunctionCallback.builder()
+ .function("WeatherInfo", new MockWeatherService())
+ .description("Get the weather in location")
+ .inputType(MockWeatherService.Request.class)
+ .build();
+ }
+
+ }
+
+}
diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/tool/MockWeatherService.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/tool/MockWeatherService.java
new file mode 100644
index 00000000000..46c09222de6
--- /dev/null
+++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/hunyuan/tool/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.autoconfigure.hunyuan.tool;
+
+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;
+
+/**
+ * Mock 3rd party weather service.
+ *
+ * @author Guo Junyu
+ */
+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, Unit.C);
+ }
+
+ /**
+ * 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(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/spring-ai-spring-boot-starters/spring-ai-starter-hunyuan/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-hunyuan/pom.xml
new file mode 100644
index 00000000000..1938682fdf9
--- /dev/null
+++ b/spring-ai-spring-boot-starters/spring-ai-starter-hunyuan/pom.xml
@@ -0,0 +1,58 @@
+
+
+
+
+ 4.0.0
+
+ org.springframework.ai
+ spring-ai
+ 1.0.0-SNAPSHOT
+ ../../pom.xml
+
+ spring-ai-hunyuan-spring-boot-starter
+ jar
+ Spring AI Starter - HunYuan
+ Spring AI HunYuan Auto Configuration
+ 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.boot
+ spring-boot-starter
+
+
+
+ org.springframework.ai
+ spring-ai-spring-boot-autoconfigure
+ ${project.parent.version}
+
+
+
+ org.springframework.ai
+ spring-ai-hunyuan
+ ${project.parent.version}
+
+
+
+