From 33f431e4a733e684d3ea9723cd8b2eb1c87c4308 Mon Sep 17 00:00:00 2001 From: Thomas Vitale Date: Fri, 18 Oct 2024 23:51:11 +0200 Subject: [PATCH] feat(openai) - Support audio input and output modality - Added support for audio input and output in OpenAI Chat Completion API - Introduced new audio-related parameters, enums, and record types - Updated ChatCompletionMessage, ChatCompletionChunk, and related classes - Added new AudioParameters, AudioOutput, and InputAudio record types - Implemented method to handle audio media content conversion - Included new model enum for GPT-4o audio preview - Extended existing API classes to accommodate audio modalities - Modified usage tracking and metadata classes to handle audio-specific token details - Improved ModelOptionsUtils with additional JSON utility methods Tests: - Updated test classes to validate audio input and output functionality - Added integration tests for multimodal audio input with streaming and non-streaming methods - Created parameterized tests for audio-enabled models - Enhanced OpenAI API integration tests to cover audio-related scenarios Docs: - Updated documentation in spring-ai-docs to explain audio multimodality support Resolves #1560 --- .../ai/openai/OpenAiChatModel.java | 37 ++- .../ai/openai/api/OpenAiApi.java | 243 +++++++++++++++--- .../OpenAiStreamFunctionCallingHelper.java | 10 +- .../src/main/resources/speech1.mp3 | Bin 0 -> 79680 bytes .../ai/openai/api/OpenAiApiIT.java | 55 +++- .../api/tool/OpenAiApiToolFunctionCallIT.java | 5 +- .../openai/chat/MessageTypeContentTests.java | 13 +- .../ai/openai/chat/OpenAiChatModelIT.java | 36 +++ .../ai/openai/chat/OpenAiRetryTests.java | 9 +- .../ai/openai/metadata/OpenAiUsageTests.java | 16 ++ .../ai/model/ModelOptionsUtils.java | 18 +- .../ROOT/pages/api/chat/comparison.adoc | 2 +- .../ROOT/pages/api/chat/openai-chat.adoc | 31 ++- 13 files changed, 403 insertions(+), 72 deletions(-) create mode 100644 models/spring-ai-openai/src/main/resources/speech1.mp3 diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java index 22fbba150..50dac57a6 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java @@ -55,6 +55,7 @@ import org.springframework.ai.chat.observation.DefaultChatModelObservationConven import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.ChatOptionsBuilder; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.Media; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.model.function.FunctionCallback; import org.springframework.ai.model.function.FunctionCallbackResolver; @@ -76,6 +77,7 @@ import org.springframework.retry.support.RetryTemplate; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; @@ -406,7 +408,7 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode chunkChoice.logprobs())) .toList(); - return new OpenAiApi.ChatCompletion(chunk.id(), choices, chunk.created(), chunk.model(), + return new OpenAiApi.ChatCompletion(chunk.id(), choices, chunk.created(), chunk.model(), chunk.serviceTier(), chunk.systemFingerprint(), "chat.completion", chunk.usage()); } @@ -423,11 +425,7 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode List contentList = new ArrayList<>( List.of(new MediaContent(message.getContent()))); - contentList.addAll(userMessage.getMedia() - .stream() - .map(media -> new MediaContent(new MediaContent.ImageUrl( - this.fromMediaData(media.getMimeType(), media.getData())))) - .toList()); + contentList.addAll(userMessage.getMedia().stream().map(this::mapToMediaContent).toList()); content = contentList; } @@ -446,7 +444,7 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode }).toList(); } return List.of(new ChatCompletionMessage(assistantMessage.getContent(), - ChatCompletionMessage.Role.ASSISTANT, null, null, toolCalls, null)); + ChatCompletionMessage.Role.ASSISTANT, null, null, toolCalls, null, null)); } else if (message.getMessageType() == MessageType.TOOL) { ToolResponseMessage toolMessage = (ToolResponseMessage) message; @@ -456,7 +454,7 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode return toolMessage.getResponses() .stream() .map(tr -> new ChatCompletionMessage(tr.responseData(), ChatCompletionMessage.Role.TOOL, tr.name(), - tr.id(), null, null)) + tr.id(), null, null, null)) .toList(); } else { @@ -508,6 +506,29 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode return request; } + private MediaContent mapToMediaContent(Media media) { + var mimeType = media.getMimeType(); + if (MimeTypeUtils.parseMimeType("audio/mp3").equals(mimeType)) { + return new MediaContent( + new MediaContent.InputAudio(fromAudioData(media.getData()), MediaContent.InputAudio.Format.MP3)); + } + if (MimeTypeUtils.parseMimeType("audio/wav").equals(mimeType)) { + return new MediaContent( + new MediaContent.InputAudio(fromAudioData(media.getData()), MediaContent.InputAudio.Format.WAV)); + } + else { + return new MediaContent( + new MediaContent.ImageUrl(this.fromMediaData(media.getMimeType(), media.getData()))); + } + } + + private String fromAudioData(Object audioData) { + if (audioData instanceof byte[] bytes) { + return Base64.getEncoder().encodeToString(bytes); + } + throw new IllegalArgumentException("Unsupported audio data type: " + audioData.getClass().getSimpleName()); + } + private String fromMediaData(MimeType mimeType, Object mediaContentData) { if (mediaContentData instanceof byte[] bytes) { // Assume the bytes are an image. So, convert the bytes to a base64 encoded diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java index 7b2dba5c2..6dd890caf 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java @@ -275,7 +275,7 @@ public class OpenAiApi { // Flux> -> Flux> .concatMapIterable(window -> { Mono monoChunk = window.reduce( - new ChatCompletionChunk(null, null, null, null, null, null, null), + new ChatCompletionChunk(null, null, null, null, null, null, null, null), (previous, current) -> this.chunkMerger.merge(previous, current)); return List.of(monoChunk); }) @@ -363,6 +363,11 @@ public class OpenAiApi { */ GPT_4_O("gpt-4o"), + /** + * Preview release for audio inputs in chat completions. + */ + GPT_4_O_AUDIO_PREVIEW("gpt-4o-audio-preview"), + /** * Affordable and intelligent small model for fast, lightweight tasks. GPT-4o mini * is cheaper and more capable than GPT-3.5 Turbo. Currently points to @@ -584,6 +589,7 @@ public class OpenAiApi { /** * Function definition. */ + @JsonInclude(JsonInclude.Include.NON_NULL) public static class Function { @JsonProperty("description") @@ -595,11 +601,17 @@ public class OpenAiApi { @JsonProperty("parameters") private Map parameters; + @JsonProperty("strict") + Boolean strict; + @JsonIgnore private String jsonSchema; + /** + * NOTE: Required by Jackson, JSON deserialization! + */ + @SuppressWarnings("unused") private Function() { - } /** @@ -611,11 +623,16 @@ public class OpenAiApi { * @param parameters The parameters the functions accepts, described as a JSON * Schema object. To describe a function that accepts no parameters, provide * the value {"type": "object", "properties": {}}. + * @param strict Whether to enable strict schema adherence when generating the + * function call. If set to true, the model will follow the exact schema + * defined in the parameters field. Only a subset of JSON Schema is supported + * when strict is true. */ - public Function(String description, String name, Map parameters) { + public Function(String description, String name, Map parameters, Boolean strict) { this.description = description; this.name = name; this.parameters = parameters; + this.strict = strict; } /** @@ -625,7 +642,7 @@ public class OpenAiApi { * @param jsonSchema tool function schema as json. */ public Function(String description, String name, String jsonSchema) { - this(description, name, ModelOptionsUtils.jsonToMap(jsonSchema)); + this(description, name, ModelOptionsUtils.jsonToMap(jsonSchema), null); } public String getDescription() { @@ -652,6 +669,14 @@ public class OpenAiApi { this.parameters = parameters; } + public Boolean getStrict() { + return this.strict; + } + + public void setStrict(Boolean strict) { + this.strict = strict; + } + public String getJsonSchema() { return this.jsonSchema; } @@ -667,11 +692,27 @@ public class OpenAiApi { } + /** + * The type of modality for the model completion. + */ + public enum OutputModality { + + // @formatter:off + @JsonProperty("audio") AUDIO, + @JsonProperty("text") TEXT; + // @formatter:on + + } + /** * Creates a model response for the given chat conversation. * * @param messages A list of messages comprising the conversation so far. * @param model ID of the model to use. + * @param store Whether to store the output of this chat completion request for use in + * OpenAI's model distillation or evals products. + * @param metadata Developer-defined tags and values used for filtering completions in + * the OpenAI's dashboard. * @param frequencyPenalty Number between -2.0 and 2.0. Positive values penalize new * tokens based on their existing frequency in the text so far, decreasing the model's * likelihood to repeat the same line verbatim. @@ -688,14 +729,22 @@ public class OpenAiApi { * @param topLogprobs An integer between 0 and 5 specifying the number of most likely * tokens to return at each token position, each with an associated log probability. * 'logprobs' must be set to 'true' if this parameter is used. - * @param maxTokens The maximum number of tokens to generate in the chat completion. - * The total length of input tokens and generated tokens is limited by the model's - * context length. + * @param maxTokens The maximum number of tokens that can be generated in the chat + * completion. This value can be used to control costs for text generated via API. + * This value is now deprecated in favor of max_completion_tokens, and is not + * compatible with o1 series models. * @param maxCompletionTokens An upper bound for the number of tokens that can be * generated for a completion, including visible output tokens and reasoning tokens. * @param n How many chat completion choices to generate for each input message. Note * that you will be charged based on the number of generated tokens across all the * choices. Keep n as 1 to minimize costs. + * @param outputModalities Output types that you would like the model to generate for + * this request. Most models are capable of generating text, which is the default: + * ["text"]. The gpt-4o-audio-preview model can also be used to generate audio. To + * request that this model generate both text and audio responses, you can use: + * ["text", "audio"]. + * @param audioParameters Parameters for audio output. Required when audio output is + * requested with outputModalities: ["audio"]. * @param presencePenalty Number between -2.0 and 2.0. Positive values penalize new * tokens based on whether they appear in the text so far, increasing the model's * likelihood to talk about new topics. @@ -707,6 +756,9 @@ public class OpenAiApi { * and parameters should return the same result. Determinism is not guaranteed, and * you should refer to the system_fingerprint response parameter to monitor changes in * the backend. + * @param serviceTier Specifies the latency tier to use for processing the request. + * This parameter is relevant for customers subscribed to the scale tier service. When + * this parameter is set, the response body will include the service_tier utilized. * @param stop Up to 4 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 @@ -740,16 +792,21 @@ public class OpenAiApi { public record ChatCompletionRequest(// @formatter:off @JsonProperty("messages") List messages, @JsonProperty("model") String model, + @JsonProperty("store") Boolean store, + @JsonProperty("metadata") Object metadata, @JsonProperty("frequency_penalty") Double frequencyPenalty, @JsonProperty("logit_bias") Map logitBias, @JsonProperty("logprobs") Boolean logprobs, @JsonProperty("top_logprobs") Integer topLogprobs, - @JsonProperty("max_tokens") Integer maxTokens, + @JsonProperty("max_tokens") @Deprecated Integer maxTokens, // Use maxCompletionTokens instead @JsonProperty("max_completion_tokens") Integer maxCompletionTokens, @JsonProperty("n") Integer n, + @JsonProperty("modalities") List outputModalities, + @JsonProperty("audio") AudioParameters audioParameters, @JsonProperty("presence_penalty") Double presencePenalty, @JsonProperty("response_format") ResponseFormat responseFormat, @JsonProperty("seed") Integer seed, + @JsonProperty("service_tier") String serviceTier, @JsonProperty("stop") List stop, @JsonProperty("stream") Boolean stream, @JsonProperty("stream_options") StreamOptions streamOptions, @@ -768,11 +825,25 @@ public class OpenAiApi { * @param temperature What sampling temperature to use, between 0 and 1. */ public ChatCompletionRequest(List messages, String model, Double temperature) { - this(messages, model, null, null, null, null, null, null, null, null, + this(messages, model, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, false, null, temperature, null, null, null, null, null); } + /** + * Shortcut constructor for a chat completion request with text and audio output. + * + * @param messages A list of messages comprising the conversation so far. + * @param model ID of the model to use. + * @param audio Parameters for audio output. Required when audio output is requested with outputModalities: ["audio"]. + */ + public ChatCompletionRequest(List messages, String model, AudioParameters audio) { + this(messages, model, null, null, null, null, null, null, + null, null, null, List.of(OutputModality.AUDIO, OutputModality.TEXT), audio, null, null, + null, null, null, false, null, null, null, + null, null, null, null); + } + /** * Shortcut constructor for a chat completion request with the given messages, model, temperature and control for streaming. * @@ -783,9 +854,9 @@ public class OpenAiApi { * as they become available, with the stream terminated by a data: [DONE] message. */ public ChatCompletionRequest(List messages, String model, Double temperature, boolean stream) { - this(messages, model, null, null, null, null, null, null, null, null, - null, null, null, stream, null, temperature, null, - null, null, null, null); + this(messages, model, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, stream, null, temperature, null, + null, null, null, null); } /** @@ -799,8 +870,8 @@ public class OpenAiApi { */ public ChatCompletionRequest(List messages, String model, List tools, Object toolChoice) { - this(messages, model, null, null, null, null, null, null, null, null, - null, null, null, false, null, 0.8, null, + this(messages, model, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, false, null, 0.8, null, tools, toolChoice, null, null); } @@ -812,8 +883,8 @@ public class OpenAiApi { * as they become available, with the stream terminated by a data: [DONE] message. */ public ChatCompletionRequest(List messages, Boolean stream) { - this(messages, null, null, null, null, null, null, null, null, - null, null, null, null, stream, null, null, null, + this(messages, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, stream, null, null, null, null, null, null, null); } @@ -824,9 +895,10 @@ public class OpenAiApi { * @return A new {@link ChatCompletionRequest} with the specified stream options. */ public ChatCompletionRequest withStreamOptions(StreamOptions streamOptions) { - return new ChatCompletionRequest(this.messages, this.model, this.frequencyPenalty, this.logitBias, this.logprobs, this.topLogprobs, this.maxTokens, this.maxCompletionTokens, this.n, this.presencePenalty, - this.responseFormat, this.seed, this.stop, this.stream, streamOptions, this.temperature, this.topP, - this.tools, this.toolChoice, this.parallelToolCalls, this.user); + return new ChatCompletionRequest(this.messages, this.model, this.store, this.metadata, this.frequencyPenalty, this.logitBias, this.logprobs, + this.topLogprobs, this.maxTokens, this.maxCompletionTokens, this.n, this.outputModalities, this.audioParameters, this.presencePenalty, + this.responseFormat, this.seed, this.serviceTier, this.stop, this.stream, streamOptions, this.temperature, this.topP, + this.tools, this.toolChoice, this.parallelToolCalls, this.user); } /** @@ -851,7 +923,40 @@ public class OpenAiApi { } /** - * Options for streaming response. + * Parameters for audio output. Required when audio output is requested with outputModalities: ["audio"]. + * @param voice Specifies the voice type. + * @param format Specifies the output audio format. + */ + @JsonInclude(Include.NON_NULL) + public record AudioParameters( + @JsonProperty("voice") Voice voice, + @JsonProperty("format") AudioResponseFormat format) { + + /** + * Specifies the voice type. + */ + public enum Voice { + @JsonProperty("alloy") ALLOY, + @JsonProperty("echo") ECHO, + @JsonProperty("fable") FABLE, + @JsonProperty("onyx") ONYX, + @JsonProperty("nova") NOVA, + @JsonProperty("shimmer") SHIMMER; + } + + /** + * Specifies the output audio format. + */ + public enum AudioResponseFormat { + @JsonProperty("mp3") MP3, + @JsonProperty("flac") FLAC, + @JsonProperty("opus") OPUS, + @JsonProperty("pcm16") PCM16, + @JsonProperty("wav") WAV; + } + } + + /** * @param includeUsage If set, an additional chunk will be streamed * before the data: [DONE] message. The usage field on this chunk * shows the token usage statistics for the entire request, and @@ -880,7 +985,10 @@ public class OpenAiApi { * the {@link Role#TOOL} role and null otherwise. * @param toolCalls The tool calls generated by the model, such as function calls. * Applicable only for {@link Role#ASSISTANT} role and null otherwise. - * @param refusal The refusal message generated by the model. + * @param refusal The refusal message by the assistant. Applicable only for + * {@link Role#ASSISTANT} role and null otherwise. + * @param audioOutput Audio response from the model. >>>>>>> bdb66e577 (OpenAI - + * Support audio input modality) */ @JsonInclude(Include.NON_NULL) public record ChatCompletionMessage(// @formatter:off @@ -890,7 +998,8 @@ public class OpenAiApi { @JsonProperty("tool_call_id") String toolCallId, @JsonProperty("tool_calls") @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) List toolCalls, - @JsonProperty("refusal") String refusal) { // @formatter:on + @JsonProperty("refusal") String refusal, + @JsonProperty("audio") AudioOutput audioOutput) { // @formatter:on /** * Create a chat completion message with the given content and role. All other @@ -899,7 +1008,8 @@ public class OpenAiApi { * @param role The role of the author of this message. */ public ChatCompletionMessage(Object content, Role role) { - this(content, role, null, null, null, null); + this(content, role, null, null, null, null, null); + } /** @@ -945,27 +1055,44 @@ public class OpenAiApi { /** * An array of content parts with a defined type. Each MediaContent can be of - * either "text" or "image_url" type. Not both. + * either "text", "image_url", or "input_audio" type. Only one option allowed. * * @param type Content type, each can be of type text or image_url. * @param text The text content of the message. * @param imageUrl The image content of the message. You can pass multiple images * by adding multiple image_url content parts. Image input is only supported when * using the gpt-4-visual-preview model. + * @param inputAudio Audio content part. */ @JsonInclude(Include.NON_NULL) public record MediaContent(// @formatter:off @JsonProperty("type") String type, @JsonProperty("text") String text, - @JsonProperty("image_url") ImageUrl imageUrl) { -// @formatter:on + @JsonProperty("image_url") ImageUrl imageUrl, + @JsonProperty("input_audio") InputAudio inputAudio) {// @formatter:on + + /** + * @param data Base64 encoded audio data. + * @param format The format of the encoded audio data. Currently supports + * "wav" and "mp3". + */ + @JsonInclude(Include.NON_NULL) + public record InputAudio(// @formatter:off + @JsonProperty("data") String data, + @JsonProperty("format") Format format) { + + public enum Format { + @JsonProperty("mp3") MP3, + @JsonProperty("wav") WAV; + }// @formatter:on + } /** * Shortcut constructor for a text content. * @param text The text content of the message. */ public MediaContent(String text) { - this("text", text, null); + this("text", text, null, null); } /** @@ -973,7 +1100,15 @@ public class OpenAiApi { * @param imageUrl The image content of the message. */ public MediaContent(ImageUrl imageUrl) { - this("image_url", null, imageUrl); + this("image_url", null, imageUrl, null); + } + + /** + * Shortcut constructor for an audio content. + * @param inputAudio The audio content of the message. + */ + public MediaContent(InputAudio inputAudio) { + this("input_audio", null, null, inputAudio); } /** @@ -1032,6 +1167,23 @@ public class OpenAiApi { @JsonProperty("arguments") String arguments) { // @formatter:on } + /** + * Audio response from the model. + * + * @param id Unique identifier for the audio response from the model. + * @param data Audio output from the model. + * @param expiresAt When the audio content will no longer be available on the + * server. + * @param transcript Transcript of the audio output from the model. + */ + @JsonInclude(Include.NON_NULL) + public record AudioOutput(// @formatter:off + @JsonProperty("id") String id, + @JsonProperty("data") String data, + @JsonProperty("expires_at") Long expiresAt, + @JsonProperty("transcript") String transcript + ) {// @formatter:on + } } /** @@ -1044,6 +1196,8 @@ public class OpenAiApi { * @param created The Unix timestamp (in seconds) of when the chat completion was * created. * @param model The model used for the chat completion. + * @param serviceTier The service tier used for processing the request. This field is + * only included if the service_tier parameter is specified in the request. * @param systemFingerprint This fingerprint represents the backend configuration that * the model runs with. Can be used in conjunction with the seed request parameter to * understand when backend changes have been made that might impact determinism. @@ -1056,9 +1210,11 @@ public class OpenAiApi { @JsonProperty("choices") List choices, @JsonProperty("created") Long created, @JsonProperty("model") String model, + @JsonProperty("service_tier") String serviceTier, @JsonProperty("system_fingerprint") String systemFingerprint, @JsonProperty("object") String object, - @JsonProperty("usage") Usage usage) { // @formatter:on + @JsonProperty("usage") Usage usage + ) {// @formatter:on /** * Chat completion choice. @@ -1073,8 +1229,7 @@ public class OpenAiApi { @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason, @JsonProperty("index") Integer index, @JsonProperty("message") ChatCompletionMessage message, - @JsonProperty("logprobs") LogProbs logprobs) { // @formatter:on - + @JsonProperty("logprobs") LogProbs logprobs) {// @formatter:on } } @@ -1083,9 +1238,11 @@ public class OpenAiApi { * Log probability information for the choice. * * @param content A list of message content tokens with log probability information. + * @param refusal A list of message refusal tokens with log probability information. */ @JsonInclude(Include.NON_NULL) - public record LogProbs(@JsonProperty("content") List content) { + public record LogProbs(@JsonProperty("content") List content, + @JsonProperty("refusal") List refusal) { /** * Message content tokens with log probability information. @@ -1168,8 +1325,9 @@ public class OpenAiApi { } /** - * Breakdown of tokens used in a completion + * Breakdown of tokens used in a completion. * + * @param audioTokens Audio input tokens generated by the model. * @param reasoningTokens Number of tokens generated by the model for reasoning. * @param acceptedPredictionTokens Number of tokens generated by the model for * accepted predictions. @@ -1185,7 +1343,6 @@ public class OpenAiApi { @JsonProperty("audio_tokens") Integer audioTokens, @JsonProperty("rejected_prediction_tokens") Integer rejectedPredictionTokens) { // @formatter:on } - } /** @@ -1198,6 +1355,8 @@ public class OpenAiApi { * @param created The Unix timestamp (in seconds) of when the chat completion was * created. Each chunk has the same timestamp. * @param model The model used for the chat completion. + * @param serviceTier The service tier used for processing the request. This field is + * only included if the service_tier parameter is specified in the request. * @param systemFingerprint This fingerprint represents the backend configuration that * the model runs with. Can be used in conjunction with the seed request parameter to * understand when backend changes have been made that might impact determinism. @@ -1207,13 +1366,14 @@ public class OpenAiApi { */ @JsonInclude(Include.NON_NULL) public record ChatCompletionChunk(// @formatter:off - @JsonProperty("id") String id, - @JsonProperty("choices") List choices, - @JsonProperty("created") Long created, - @JsonProperty("model") String model, - @JsonProperty("system_fingerprint") String systemFingerprint, - @JsonProperty("object") String object, - @JsonProperty("usage") Usage usage) { // @formatter:on + @JsonProperty("id") String id, + @JsonProperty("choices") List choices, + @JsonProperty("created") Long created, + @JsonProperty("model") String model, + @JsonProperty("service_tier") String serviceTier, + @JsonProperty("system_fingerprint") String systemFingerprint, + @JsonProperty("object") String object, + @JsonProperty("usage") Usage usage) {// @formatter:on /** * Chat completion choice. @@ -1229,6 +1389,7 @@ public class OpenAiApi { @JsonProperty("index") Integer index, @JsonProperty("delta") ChatCompletionMessage delta, @JsonProperty("logprobs") LogProbs logprobs) { // @formatter:on + } } diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiStreamFunctionCallingHelper.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiStreamFunctionCallingHelper.java index 9bdd2ea18..a65f9fbcc 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiStreamFunctionCallingHelper.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiStreamFunctionCallingHelper.java @@ -38,6 +38,7 @@ import org.springframework.util.CollectionUtils; * It can merge the streamed ChatCompletionChunk in case of function calling message. * * @author Christian Tzolov + * @author Thomas Vitale * @since 0.8.1 */ public class OpenAiStreamFunctionCallingHelper { @@ -57,6 +58,7 @@ public class OpenAiStreamFunctionCallingHelper { String id = (current.id() != null ? current.id() : previous.id()); Long created = (current.created() != null ? current.created() : previous.created()); String model = (current.model() != null ? current.model() : previous.model()); + String serviceTier = (current.serviceTier() != null ? current.serviceTier() : previous.serviceTier()); String systemFingerprint = (current.systemFingerprint() != null ? current.systemFingerprint() : previous.systemFingerprint()); String object = (current.object() != null ? current.object() : previous.object()); @@ -67,7 +69,7 @@ public class OpenAiStreamFunctionCallingHelper { ChunkChoice choice = merge(previousChoice0, currentChoice0); List chunkChoices = choice == null ? List.of() : List.of(choice); - return new ChatCompletionChunk(id, chunkChoices, created, model, systemFingerprint, object, usage); + return new ChatCompletionChunk(id, chunkChoices, created, model, serviceTier, systemFingerprint, object, usage); } private ChunkChoice merge(ChunkChoice previous, ChunkChoice current) { @@ -93,6 +95,8 @@ public class OpenAiStreamFunctionCallingHelper { String name = (current.name() != null ? current.name() : previous.name()); String toolCallId = (current.toolCallId() != null ? current.toolCallId() : previous.toolCallId()); String refusal = (current.refusal() != null ? current.refusal() : previous.refusal()); + ChatCompletionMessage.AudioOutput audioOutput = (current.audioOutput() != null ? current.audioOutput() + : previous.audioOutput()); List toolCalls = new ArrayList<>(); ToolCall lastPreviousTooCall = null; @@ -122,7 +126,7 @@ public class OpenAiStreamFunctionCallingHelper { toolCalls.add(lastPreviousTooCall); } } - return new ChatCompletionMessage(content, role, name, toolCallId, toolCalls, refusal); + return new ChatCompletionMessage(content, role, name, toolCallId, toolCalls, refusal, audioOutput); } private ToolCall merge(ToolCall previous, ToolCall current) { @@ -197,7 +201,7 @@ public class OpenAiStreamFunctionCallingHelper { chunkChoice.logprobs())) .toList(); - return new OpenAiApi.ChatCompletion(chunk.id(), choices, chunk.created(), chunk.model(), + return new OpenAiApi.ChatCompletion(chunk.id(), choices, chunk.created(), chunk.model(), chunk.serviceTier(), chunk.systemFingerprint(), "chat.completion", null); } diff --git a/models/spring-ai-openai/src/main/resources/speech1.mp3 b/models/spring-ai-openai/src/main/resources/speech1.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..68cb2a30da11f2761c5149e9b49fbfe9397a4ac5 GIT binary patch literal 79680 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 literal 0 HcmV?d00001 diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/OpenAiApiIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/OpenAiApiIT.java index a07d400e1..d338af59b 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/OpenAiApiIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/OpenAiApiIT.java @@ -16,12 +16,8 @@ package org.springframework.ai.openai.api; -import java.util.List; - import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; -import reactor.core.publisher.Flux; - import org.springframework.ai.openai.api.OpenAiApi.ChatCompletion; import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionChunk; import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionMessage; @@ -29,12 +25,19 @@ import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionMessage.Role; import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest; import org.springframework.ai.openai.api.OpenAiApi.Embedding; import org.springframework.ai.openai.api.OpenAiApi.EmbeddingList; +import org.springframework.core.io.ClassPathResource; import org.springframework.http.ResponseEntity; +import reactor.core.publisher.Flux; + +import java.io.IOException; +import java.util.Base64; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; /** * @author Christian Tzolov + * @author Thomas Vitale */ @EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") public class OpenAiApiIT { @@ -71,4 +74,48 @@ public class OpenAiApiIT { assertThat(response.getBody().data().get(0).embedding()).hasSize(1536); } + @Test + void inputAudio() throws IOException { + var audioData = new ClassPathResource("speech1.mp3").getContentAsByteArray(); + List content = List + .of(new ChatCompletionMessage.MediaContent("What is this recording about?"), + new ChatCompletionMessage.MediaContent(new ChatCompletionMessage.MediaContent.InputAudio( + Base64.getEncoder().encodeToString(audioData), + ChatCompletionMessage.MediaContent.InputAudio.Format.MP3))); + ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage(content, Role.USER); + ChatCompletionRequest chatCompletionRequest = new ChatCompletionRequest(List.of(chatCompletionMessage), + OpenAiApi.ChatModel.GPT_4_O_AUDIO_PREVIEW.getValue(), 0.0); + ResponseEntity response = openAiApi.chatCompletionEntity(chatCompletionRequest); + + assertThat(response).isNotNull(); + assertThat(response.getBody()).isNotNull(); + + assertThat(response.getBody().usage().promptTokensDetails().audioTokens()).isGreaterThan(0); + assertThat(response.getBody().usage().completionTokenDetails().audioTokens()).isEqualTo(0); + + assertThat(response.getBody().choices().get(0).message().content()).containsIgnoringCase("hobbits"); + } + + @Test + void outputAudio() { + ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage( + "What is the magic spell to make objects fly?", Role.USER); + ChatCompletionRequest.AudioParameters audioParameters = new ChatCompletionRequest.AudioParameters( + ChatCompletionRequest.AudioParameters.Voice.NOVA, + ChatCompletionRequest.AudioParameters.AudioResponseFormat.MP3); + ChatCompletionRequest chatCompletionRequest = new ChatCompletionRequest(List.of(chatCompletionMessage), + OpenAiApi.ChatModel.GPT_4_O_AUDIO_PREVIEW.getValue(), audioParameters); + ResponseEntity response = openAiApi.chatCompletionEntity(chatCompletionRequest); + + assertThat(response).isNotNull(); + assertThat(response.getBody()).isNotNull(); + + assertThat(response.getBody().usage().promptTokensDetails().audioTokens()).isEqualTo(0); + assertThat(response.getBody().usage().completionTokenDetails().audioTokens()).isGreaterThan(0); + + assertThat(response.getBody().choices().get(0).message().audioOutput().data()).isNotNull(); + assertThat(response.getBody().choices().get(0).message().audioOutput().transcript()) + .containsIgnoringCase("leviosa"); + } + } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/tool/OpenAiApiToolFunctionCallIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/tool/OpenAiApiToolFunctionCallIT.java index a7b1cb48a..33f50b1f4 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/tool/OpenAiApiToolFunctionCallIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/tool/OpenAiApiToolFunctionCallIT.java @@ -43,6 +43,7 @@ import static org.assertj.core.api.Assertions.assertThat; * https://platform.openai.com/docs/guides/function-calling/parallel-function-calling * * @author Christian Tzolov + * @author Thomas Vitale */ @EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") public class OpenAiApiToolFunctionCallIT { @@ -95,7 +96,7 @@ public class OpenAiApiToolFunctionCallIT { }, "required": ["location", "lat", "lon", "unit"] } - """))); + """), null)); List messages = new ArrayList<>(List.of(message)); @@ -128,7 +129,7 @@ public class OpenAiApiToolFunctionCallIT { // extend conversation with function response. messages.add(new ChatCompletionMessage("" + weatherResponse.temp() + weatherRequest.unit(), Role.TOOL, - functionName, toolCall.id(), null, null)); + functionName, toolCall.id(), null, null, null)); } } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/MessageTypeContentTests.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/MessageTypeContentTests.java index 8b4ac894c..0c6b8c5a6 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/MessageTypeContentTests.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/MessageTypeContentTests.java @@ -48,6 +48,7 @@ import static org.mockito.BDDMockito.given; /** * @author Christian Tzolov + * @author Thomas Vitale */ @ExtendWith(MockitoExtension.class) @SuppressWarnings("unchecked") @@ -64,12 +65,12 @@ public class MessageTypeContentTests { @Captor ArgumentCaptor> headersCaptor; - Flux fluxResponse = Flux - .generate(() -> new ChatCompletionChunk("id", List.of(), 0L, "model", "fp", "object", null), (state, sink) -> { - sink.next(state); - sink.complete(); - return state; - }); + Flux fluxResponse = Flux.generate( + () -> new ChatCompletionChunk("id", List.of(), 0l, "model", null, "fp", "object", null), (state, sink) -> { + sink.next(state); + sink.complete(); + return state; + }); @BeforeEach public void beforeEach() { diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatModelIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatModelIT.java index 541ae7556..a8e5c5469 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatModelIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatModelIT.java @@ -433,6 +433,42 @@ public class OpenAiChatModelIT extends AbstractIT { assertThat(content).containsAnyOf("bowl", "basket", "fruit stand"); } + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "gpt-4o-audio-preview" }) + void multiModalityInputAudio(String modelName) { + var audioResource = new ClassPathResource("speech1.mp3"); + var userMessage = new UserMessage("What is this recording about?", + List.of(new Media(MimeTypeUtils.parseMimeType("audio/mp3"), audioResource))); + + ChatResponse response = chatModel + .call(new Prompt(List.of(userMessage), OpenAiChatOptions.builder().withModel(modelName).build())); + + logger.info(response.getResult().getOutput().getContent()); + assertThat(response.getResult().getOutput().getContent()).containsIgnoringCase("hobbits"); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "gpt-4o-audio-preview" }) + void streamingMultiModalityInputAudio(String modelName) { + var audioResource = new ClassPathResource("speech1.mp3"); + var userMessage = new UserMessage("What is this recording about?", + List.of(new Media(MimeTypeUtils.parseMimeType("audio/mp3"), audioResource))); + + Flux response = chatModel + .stream(new Prompt(List.of(userMessage), OpenAiChatOptions.builder().withModel(modelName).build())); + + String content = response.collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getContent) + .collect(Collectors.joining()); + logger.info("Response: {}", content); + assertThat(content).containsIgnoringCase("hobbits"); + } + @Test void validateCallResponseMetadata() { String model = OpenAiApi.ChatModel.GPT_3_5_TURBO.getName(); diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiRetryTests.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiRetryTests.java index ec09403ea..f225095a9 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiRetryTests.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiRetryTests.java @@ -78,6 +78,7 @@ import static org.mockito.BDDMockito.given; /** * @author Christian Tzolov + * @author Thomas Vitale */ @SuppressWarnings("unchecked") @ExtendWith(MockitoExtension.class) @@ -128,8 +129,8 @@ public class OpenAiRetryTests { var choice = new ChatCompletion.Choice(ChatCompletionFinishReason.STOP, 0, new ChatCompletionMessage("Response", Role.ASSISTANT), null); - ChatCompletion expectedChatCompletion = new ChatCompletion("id", List.of(choice), 666L, "model", null, null, - new OpenAiApi.Usage(10, 10, 10)); + ChatCompletion expectedChatCompletion = new ChatCompletion("id", List.of(choice), 666l, "model", null, null, + null, new OpenAiApi.Usage(10, 10, 10)); given(this.openAiApi.chatCompletionEntity(isA(ChatCompletionRequest.class), any())) .willThrow(new TransientAiException("Transient Error 1")) @@ -157,8 +158,8 @@ public class OpenAiRetryTests { var choice = new ChatCompletionChunk.ChunkChoice(ChatCompletionFinishReason.STOP, 0, new ChatCompletionMessage("Response", Role.ASSISTANT), null); - ChatCompletionChunk expectedChatCompletion = new ChatCompletionChunk("id", List.of(choice), 666L, "model", null, - null, null); + ChatCompletionChunk expectedChatCompletion = new ChatCompletionChunk("id", List.of(choice), 666l, "model", null, + null, null, null); given(this.openAiApi.chatCompletionStream(isA(ChatCompletionRequest.class), any())) .willThrow(new TransientAiException("Transient Error 1")) diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/metadata/OpenAiUsageTests.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/metadata/OpenAiUsageTests.java index 806af6c61..92e8f595b 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/metadata/OpenAiUsageTests.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/metadata/OpenAiUsageTests.java @@ -83,6 +83,22 @@ class OpenAiUsageTests { assertThat(usage.getCompletionTokenDetails().rejectedPredictionTokens()).isEqualTo(0); } + @Test + void whenCompletionTokenDetailsIsNull() { + OpenAiApi.Usage openAiUsage = new OpenAiApi.Usage(100, 200, 300, null, null); + OpenAiUsage usage = OpenAiUsage.from(openAiUsage); + assertThat(usage.getTotalTokens()).isEqualTo(300); + assertThat(usage.getReasoningTokens()).isEqualTo(0); + } + + @Test + void whenReasoningTokensIsNull() { + OpenAiApi.Usage openAiUsage = new OpenAiApi.Usage(100, 200, 300, null, + new OpenAiApi.Usage.CompletionTokenDetails(null, null, null, null)); + OpenAiUsage usage = OpenAiUsage.from(openAiUsage); + assertThat(usage.getReasoningTokens()).isEqualTo(0); + } + @Test void whenCompletionTokenDetailsIsPresent() { OpenAiApi.Usage openAiUsage = new OpenAiApi.Usage(100, 200, 300, null, diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java b/spring-ai-core/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java index 79940be2a..65e1a824f 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java @@ -124,6 +124,20 @@ public abstract class ModelOptionsUtils { } } + /** + * Converts the given object to a JSON string. + * @param object the object to convert to a JSON string. + * @return the JSON string. + */ + public static String toJsonStringPrettyPrinter(Object object) { + try { + return OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(object); + } + catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + /** * Merges the source object into the target object and returns an object represented * by the given class. The JSON property names are used to match the fields to merge. @@ -361,8 +375,8 @@ public abstract class ModelOptionsUtils { } ObjectNode node = SCHEMA_GENERATOR_CACHE.get().generateSchema(clazz); - if (toUpperCaseTypeValues) { // Required for OpenAPI 3.0 (at least Vertex AI - // version of it). + // Required for OpenAPI 3.0 (at least Vertex AI version of it). + if (toUpperCaseTypeValues) { toUpperCaseTypeValues(node); } diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/comparison.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/comparison.adoc index 119bdbcd5..72fa66376 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/comparison.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/comparison.adoc @@ -30,7 +30,7 @@ This table compares various Chat Models supported by Spring AI, detailing their | xref::api/chat/nvidia-chat.adoc[NVIDIA (OpenAI-proxy)] | text, image ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::yes.svg[width=16] | xref::api/chat/oci-genai/cohere-chat.adoc[OCI GenAI/Cohere] | text ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::yes.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] | xref::api/chat/ollama-chat.adoc[Ollama] | text, image ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] -| xref::api/chat/openai-chat.adoc[OpenAI] | text, image ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::no.svg[width=12] ^a| image::yes.svg[width=16] +| xref::api/chat/openai-chat.adoc[OpenAI] | text, image, audio ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::no.svg[width=12] ^a| image::yes.svg[width=16] | xref::api/chat/qianfan-chat.adoc[QianFan] | text ^a| image::no.svg[width=12] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] | xref::api/chat/zhipuai-chat.adoc[ZhiPu AI] | text ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] | xref::api/chat/watsonx-ai-chat.adoc[Watsonx.AI] | text ^a| image::no.svg[width=12] ^a| image::yes.svg[width=16] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc index 14fb21a95..7f285ccd0 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc @@ -164,7 +164,11 @@ Read more about xref:api/chat/functions/openai-chat-functions.adoc[OpenAI Functi == Multimodal Multimodality refers to a model's ability to simultaneously understand and process information from various sources, including text, images, audio, and other data formats. -OpenAI models that offer multimodal support include `gpt-4`, `gpt-4o`, and `gpt-4o-mini`. +OpenAI supports text, vision, and audio input modalities. + +=== Vision + +OpenAI models that offer vision multimodal support include `gpt-4`, `gpt-4o`, and `gpt-4o-mini`. Refer to the link:https://platform.openai.com/docs/guides/vision[Vision] guide for more information. The OpenAI link:https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages[User Message API] can incorporate a list of base64-encoded images or image urls with the message. @@ -215,6 +219,31 @@ for carrying. The bowl is placed on a flat surface with a neutral-colored backgr view of the fruit inside. ---- +=== Audio + +OpenAI models that offer audio multimodal support include `gpt-4o-audio-preview`. +Refer to the link:https://platform.openai.com/docs/guides/audio[Audio] guide for more information. + +The OpenAI link:https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages[User Message API] can incorporate a list of base64-encoded audio files with the message. +Spring AI’s link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/Message.java[Message] interface facilitates multimodal AI models by introducing the link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/Media.java[Media] type. +This type encompasses data and details regarding media attachments in messages, utilizing Spring’s `org.springframework.util.MimeType` and a `org.springframework.core.io.Resource` for the raw media data. +Currently, OpenAI support only the following media types: `audio/mp3` and `audio/wav`. + +Below is a code example excerpted from link:https://github.com/spring-projects/spring-ai/blob/c9a3e66f90187ce7eae7eb78c462ec622685de6c/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatModelIT.java#L442[OpenAiChatModelIT.java], illustrating the fusion of user text with an audio file using the `gpt-4o-audio-preview` model. + +[source,java] +---- +var audioResource = new ClassPathResource("speech1.mp3"); + +var userMessage = new UserMessage("What is this recording about?", + List.of(new Media(MimeTypeUtils.parseMimeType("audio/mp3"), audioResource))); + +ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), + OpenAiChatOptions.builder().withModel(OpenAiApi.ChatModel.GPT_4_O_AUDIO_PREVIEW).build())); +---- + +TIP: You can pass multiple audio files as well. + == Structured Outputs OpenAI provides custom https://platform.openai.com/docs/guides/structured-outputs[Structured Outputs] APIs that ensure your model generates responses conforming strictly to your provided `JSON Schema`.