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 3b710c696..f798323d8 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 @@ -15,7 +15,16 @@ */ package org.springframework.ai.openai; -import io.micrometer.observation.ObservationRegistry; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.chat.messages.AssistantMessage; @@ -26,8 +35,16 @@ import org.springframework.ai.chat.metadata.ChatGenerationMetadata; import org.springframework.ai.chat.metadata.ChatResponseMetadata; import org.springframework.ai.chat.metadata.EmptyUsage; import org.springframework.ai.chat.metadata.RateLimit; -import org.springframework.ai.chat.model.*; -import org.springframework.ai.chat.observation.*; +import org.springframework.ai.chat.model.AbstractToolCallSupport; +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.observation.ChatModelObservationContext; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.chat.observation.ChatModelObservationDocumentation; +import org.springframework.ai.chat.observation.ChatModelRequestOptions; +import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.model.ModelOptionsUtils; @@ -52,13 +69,13 @@ 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.MultiValueMap; import org.springframework.util.StringUtils; + +import io.micrometer.observation.ObservationRegistry; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; - /** * {@link ChatModel} and {@link StreamingChatModel} implementation for {@literal OpenAI} * backed by {@link OpenAiApi}. @@ -204,7 +221,7 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode .observe(() -> { ResponseEntity completionEntity = this.retryTemplate - .execute(ctx -> this.openAiApi.chatCompletionEntity(request)); + .execute(ctx -> this.openAiApi.chatCompletionEntity(request, getAdditionalHttpHeaders(prompt))); var chatCompletion = completionEntity.getBody(); @@ -258,7 +275,7 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode ChatCompletionRequest request = createRequest(prompt, true); Flux completionChunks = this.retryTemplate - .execute(ctx -> this.openAiApi.chatCompletionStream(request)); + .execute(ctx -> this.openAiApi.chatCompletionStream(request, getAdditionalHttpHeaders(prompt))); // For chunked responses, only the first chunk contains the choice role. // The rest of the chunks with same ID share the same role. @@ -315,6 +332,16 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode }); } + private MultiValueMap getAdditionalHttpHeaders(Prompt prompt) { + + Map headers = new HashMap<>(this.defaultOptions.getHttpHeaders()); + if (prompt.getOptions() != null && prompt.getOptions() instanceof OpenAiChatOptions chatOptions) { + headers.putAll(chatOptions.getHttpHeaders()); + } + return CollectionUtils.toMultiValueMap( + headers.entrySet().stream().collect(Collectors.toMap(e -> e.getKey(), e -> List.of(e.getValue())))); + } + private Generation buildGeneration(Choice choice, Map metadata) { List toolCalls = choice.message().toolCalls() == null ? List.of() : choice.message() diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java index b7067f9d7..bd8373377 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java @@ -16,6 +16,7 @@ package org.springframework.ai.openai; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -169,6 +170,13 @@ public class OpenAiChatOptions implements FunctionCallingOptions, ChatOptions { @NestedConfigurationProperty @JsonIgnore private Set functions = new HashSet<>(); + + /** + * Optional HTTP headers to be added to the chat completion request. + */ + @NestedConfigurationProperty + @JsonIgnore + private Map httpHeaders = new HashMap<>(); // @formatter:on public static Builder builder() { @@ -299,6 +307,12 @@ public class OpenAiChatOptions implements FunctionCallingOptions, ChatOptions { return this; } + public Builder withHttpHeaders(Map httpHeaders) { + Assert.notNull(httpHeaders, "HTTP headers must not be null"); + this.options.httpHeaders = httpHeaders; + return this; + } + public OpenAiChatOptions build() { return this.options; } @@ -478,6 +492,14 @@ public class OpenAiChatOptions implements FunctionCallingOptions, ChatOptions { this.functions = functionNames; } + public Map getHttpHeaders() { + return this.httpHeaders; + } + + public void setHttpHeaders(Map httpHeaders) { + this.httpHeaders = httpHeaders; + } + @Override public int hashCode() { final int prime = 31; @@ -662,6 +684,7 @@ public class OpenAiChatOptions implements FunctionCallingOptions, ChatOptions { .withParallelToolCalls(fromOptions.getParallelToolCalls()) .withFunctionCallbacks(fromOptions.getFunctionCallbacks()) .withFunctions(fromOptions.getFunctions()) + .withHttpHeaders(fromOptions.getHttpHeaders()) .build(); } 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 fbb1426e2..c71cb02a5 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 @@ -15,34 +15,39 @@ */ package org.springframework.ai.openai.api; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonInclude.Include; -import com.fasterxml.jackson.annotation.JsonProperty; -import org.springframework.ai.model.ChatModelDescription; -import org.springframework.ai.model.ModelOptionsUtils; -import org.springframework.ai.openai.api.common.OpenAiApiConstants; -import org.springframework.ai.retry.RetryUtils; -import org.springframework.ai.util.api.ApiUtils; -import org.springframework.boot.context.properties.bind.ConstructorBinding; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.ResponseEntity; -import org.springframework.util.Assert; -import org.springframework.util.CollectionUtils; -import org.springframework.web.client.ResponseErrorHandler; -import org.springframework.web.client.RestClient; -import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Predicate; -// @formatter:off +import org.springframework.ai.model.ChatModelDescription; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.openai.api.common.OpenAiApiConstants; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.boot.context.properties.bind.ConstructorBinding; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + /** - * Single class implementation of the OpenAI Chat - * Completion API and OpenAI Embedding API. + * Single class implementation of the + * OpenAI Chat Completion + * API and OpenAI + * Embedding API. * * @author Christian Tzolov * @author Michael Lavelle @@ -52,7 +57,9 @@ import java.util.function.Predicate; public class OpenAiApi { public static final OpenAiApi.ChatModel DEFAULT_CHAT_MODEL = ChatModel.GPT_4_O; + public static final String DEFAULT_EMBEDDING_MODEL = EmbeddingModel.TEXT_EMBEDDING_ADA_002.getValue(); + private static final Predicate SSE_DONE_PREDICATE = "[DONE]"::equals; private final String completionsPath; @@ -65,180 +72,193 @@ public class OpenAiApi { /** * Create a new chat completion api with base URL set to https://api.openai.com - * - * @param openAiToken OpenAI apiKey. + * @param apiKey OpenAI apiKey. */ - public OpenAiApi(String openAiToken) { - this(OpenAiApiConstants.DEFAULT_BASE_URL, openAiToken); + public OpenAiApi(String apiKey) { + this(OpenAiApiConstants.DEFAULT_BASE_URL, apiKey); } /** * Create a new chat completion api. - * * @param baseUrl api base URL. - * @param openAiToken OpenAI apiKey. + * @param apiKey OpenAI apiKey. */ - public OpenAiApi(String baseUrl, String openAiToken) { - this(baseUrl, openAiToken, RestClient.builder(), WebClient.builder()); + public OpenAiApi(String baseUrl, String apiKey) { + this(baseUrl, apiKey, RestClient.builder(), WebClient.builder()); } /** * Create a new chat completion api. - * * @param baseUrl api base URL. - * @param openAiToken OpenAI apiKey. + * @param apiKey OpenAI apiKey. * @param restClientBuilder RestClient builder. */ - public OpenAiApi(String baseUrl, String openAiToken, RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder) { - this(baseUrl, openAiToken, restClientBuilder, webClientBuilder, RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER); + public OpenAiApi(String baseUrl, String apiKey, RestClient.Builder restClientBuilder, + WebClient.Builder webClientBuilder) { + this(baseUrl, apiKey, restClientBuilder, webClientBuilder, RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER); } /** * Create a new chat completion api. - * * @param baseUrl api base URL. - * @param openAiToken OpenAI apiKey. + * @param apiKey OpenAI apiKey. * @param restClientBuilder RestClient builder. * @param responseErrorHandler Response error handler. */ - public OpenAiApi(String baseUrl, String openAiToken, RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler) { - this(baseUrl, openAiToken, "/v1/chat/completions", "/v1/embeddings", + public OpenAiApi(String baseUrl, String apiKey, RestClient.Builder restClientBuilder, + WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler) { + this(baseUrl, apiKey, "/v1/chat/completions", "/v1/embeddings", restClientBuilder, webClientBuilder, + responseErrorHandler); + } + + /** + * Create a new chat completion api. + * @param baseUrl api base URL. + * @param apiKey OpenAI apiKey. + * @param restClientBuilder RestClient builder. + * @param responseErrorHandler Response error handler. + */ + public OpenAiApi(String baseUrl, String apiKey, String completionsPath, String embeddingsPath, + RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, + ResponseErrorHandler responseErrorHandler) { + + this(baseUrl, apiKey, CollectionUtils.toMultiValueMap(Map.of()), completionsPath, embeddingsPath, restClientBuilder, webClientBuilder, responseErrorHandler); } /** * Create a new chat completion api. - * * @param baseUrl api base URL. - * @param openAiToken OpenAI apiKey. + * @param apiKey OpenAI apiKey. + * @param headers the http headers to use. * @param restClientBuilder RestClient builder. * @param responseErrorHandler Response error handler. */ - public OpenAiApi(String baseUrl, String openAiToken, String completionsPath, String embeddingsPath, - RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler) { + public OpenAiApi(String baseUrl, String apiKey, MultiValueMap headers, String completionsPath, + String embeddingsPath, RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, + ResponseErrorHandler responseErrorHandler) { + Assert.hasText(completionsPath, "Completions Path must not be null"); Assert.hasText(embeddingsPath, "Embeddings Path must not be null"); + Assert.notNull(headers, "Headers must not be null"); + this.completionsPath = completionsPath; this.embeddingsPath = embeddingsPath; - this.restClient = restClientBuilder - .baseUrl(baseUrl) - .defaultHeaders(ApiUtils.getJsonContentHeaders(openAiToken)) - .defaultStatusHandler(responseErrorHandler) - .build(); + // @formatter:off + this.restClient = restClientBuilder.baseUrl(baseUrl) + .defaultHeaders(h -> { + h.setBearerAuth(apiKey); + h.setContentType(MediaType.APPLICATION_JSON); + h.addAll(headers); + }) + .defaultStatusHandler(responseErrorHandler) + .build(); this.webClient = webClientBuilder - .baseUrl(baseUrl) - .defaultHeaders(ApiUtils.getJsonContentHeaders(openAiToken)) - .build(); + .baseUrl(baseUrl) + .defaultHeaders(h -> { + h.setBearerAuth(apiKey); + h.setContentType(MediaType.APPLICATION_JSON); + h.addAll(headers); + }) + .build();// @formatter:on } /** - * OpenAI Chat Completion Models: - * - GPT-4o - * - GPT-4o mini - * - GPT-4 and GPT-4 Turbo - * - GPT-3.5 Turbo. + * OpenAI Chat Completion Models: - + * GPT-4o - + * GPT-4o mini - + * GPT-4 and + * GPT-4 Turbo - + * GPT-3.5 Turbo. */ public enum ChatModel implements ChatModelDescription { + /** - * Multimodal flagship model that’s cheaper and faster than GPT-4 Turbo. - * Currently points to gpt-4o-2024-05-13. + * Multimodal flagship model that’s cheaper and faster than GPT-4 Turbo. Currently + * points to gpt-4o-2024-05-13. */ GPT_4_O("gpt-4o"), /** - * 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 gpt-4o-mini-2024-07-18. + * 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 + * gpt-4o-mini-2024-07-18. */ GPT_4_O_MINI("gpt-4o-mini"), /** - * GPT-4 Turbo with Vision - * The latest GPT-4 Turbo model with vision capabilities. - * Vision requests can now use JSON mode and function calling. - * Currently points to gpt-4-turbo-2024-04-09. + * GPT-4 Turbo with Vision The latest GPT-4 Turbo model with vision capabilities. + * Vision requests can now use JSON mode and function calling. Currently points to + * gpt-4-turbo-2024-04-09. */ GPT_4_TURBO("gpt-4-turbo"), /** - * GPT-4 Turbo with Vision model. Vision requests can now use JSON mode and function calling. + * GPT-4 Turbo with Vision model. Vision requests can now use JSON mode and + * function calling. */ GPT_4_TURBO_2204_04_09("gpt-4-turbo-2024-04-09"), /** - * (New) GPT-4 Turbo - latest GPT-4 model intended to reduce cases - * of “laziness” where the model doesn’t complete a task. - * Returns a maximum of 4,096 output tokens. - * Context window: 128k tokens + * (New) GPT-4 Turbo - latest GPT-4 model intended to reduce cases of “laziness” + * where the model doesn’t complete a task. Returns a maximum of 4,096 output + * tokens. Context window: 128k tokens */ GPT_4_0125_PREVIEW("gpt-4-0125-preview"), /** - * Currently points to gpt-4-0125-preview - model featuring improved - * instruction following, JSON mode, reproducible outputs, - * parallel function calling, and more. - * Returns a maximum of 4,096 output tokens - * Context window: 128k tokens + * Currently points to gpt-4-0125-preview - model featuring improved instruction + * following, JSON mode, reproducible outputs, parallel function calling, and + * more. Returns a maximum of 4,096 output tokens Context window: 128k tokens */ GPT_4_TURBO_PREVIEW("gpt-4-turbo-preview"), /** - * GPT-4 with the ability to understand images, in addition - * to all other GPT-4 Turbo capabilities. Currently points - * to gpt-4-1106-vision-preview. - * Returns a maximum of 4,096 output tokens - * Context window: 128k tokens + * GPT-4 with the ability to understand images, in addition to all other GPT-4 + * Turbo capabilities. Currently points to gpt-4-1106-vision-preview. Returns a + * maximum of 4,096 output tokens Context window: 128k tokens */ @Deprecated(since = "1.0.0-M2", forRemoval = true) // Replaced by GPT_4_O GPT_4_VISION_PREVIEW("gpt-4-vision-preview"), /** - * Currently points to gpt-4-0613. - * Snapshot of gpt-4 from June 13th 2023 with improved - * function calling support. - * Context window: 8k tokens + * Currently points to gpt-4-0613. Snapshot of gpt-4 from June 13th 2023 with + * improved function calling support. Context window: 8k tokens */ GPT_4("gpt-4"), /** - * Currently points to gpt-4-32k-0613. - * Snapshot of gpt-4-32k from June 13th 2023 with improved - * function calling support. - * Context window: 32k tokens + * Currently points to gpt-4-32k-0613. Snapshot of gpt-4-32k from June 13th 2023 + * with improved function calling support. Context window: 32k tokens */ @Deprecated(since = "1.0.0-M2", forRemoval = true) // Replaced by GPT_4_O GPT_4_32K("gpt-4-32k"), /** - *Currently points to gpt-3.5-turbo-0125. - * model with higher accuracy at responding in requested - * formats and a fix for a bug which caused a text - * encoding issue for non-English language function calls. - * Returns a maximum of 4,096 - * Context window: 16k tokens + * Currently points to gpt-3.5-turbo-0125. model with higher accuracy at + * responding in requested formats and a fix for a bug which caused a text + * encoding issue for non-English language function calls. Returns a maximum of + * 4,096 Context window: 16k tokens */ GPT_3_5_TURBO("gpt-3.5-turbo"), /** - * (new) The latest GPT-3.5 Turbo model with higher accuracy - * at responding in requested formats and a fix for a bug - * which caused a text encoding issue for non-English - * language function calls. - * Returns a maximum of 4,096 - * Context window: 16k tokens + * (new) The latest GPT-3.5 Turbo model with higher accuracy at responding in + * requested formats and a fix for a bug which caused a text encoding issue for + * non-English language function calls. Returns a maximum of 4,096 Context window: + * 16k tokens */ GPT_3_5_TURBO_0125("gpt-3.5-turbo-0125"), /** - * GPT-3.5 Turbo model with improved instruction following, - * JSON mode, reproducible outputs, parallel function calling, - * and more. Returns a maximum of 4,096 output tokens. - * Context window: 16k tokens. + * GPT-3.5 Turbo model with improved instruction following, JSON mode, + * reproducible outputs, parallel function calling, and more. Returns a maximum of + * 4,096 output tokens. Context window: 16k tokens. */ GPT_3_5_TURBO_1106("gpt-3.5-turbo-1106"); - public final String value; + public final String value; ChatModel(String value) { this.value = value; @@ -252,16 +272,18 @@ public class OpenAiApi { public String getName() { return this.value; } + } /** - * Represents a tool the model may call. Currently, only functions are supported as a tool. + * 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( + public record FunctionTool(// @formatter:off @JsonProperty("type") Type type, @JsonProperty("function") Function function) { @@ -311,60 +333,77 @@ public class OpenAiApi { this(description, name, ModelOptionsUtils.jsonToMap(jsonSchema)); } } - } + }// @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 frequencyPenalty Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing - * frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim. - * @param logitBias Modify the likelihood of specified tokens appearing in the completion. Accepts a JSON object - * that maps tokens (specified by their token ID in the tokenizer) to an associated bias value from -100 to 100. - * Mathematically, the bias is added to the logits generated by the model prior to sampling. The exact effect will - * vary per model, but values between -1 and 1 should decrease or increase likelihood of selection; values like -100 - * or 100 should result in a ban or exclusive selection of the relevant token. - * @param logprobs Whether to return log probabilities of the output tokens or not. If true, returns the log - * probabilities of each output token returned in the 'content' of 'message'. - * @param topLogprobs An integer between 0 and 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 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 presencePenalty Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they - * appear in the text so far, increasing the model's likelihood to talk about new topics. - * @param responseFormat An object specifying the format that the model must output. Setting to { "type": - * "json_object" } enables JSON mode, which guarantees the message the model generates is valid JSON. - * @param seed This feature is in Beta. If specified, our system will make a best effort to sample - * deterministically, such that repeated requests with the same seed 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 frequencyPenalty Number between -2.0 and 2.0. Positive values penalize new + * tokens based on their existing frequency in the text so far, decreasing the model's + * likelihood to repeat the same line verbatim. + * @param logitBias Modify the likelihood of specified tokens appearing in the + * completion. Accepts a JSON object that maps tokens (specified by their token ID in + * the tokenizer) to an associated bias value from -100 to 100. Mathematically, the + * bias is added to the logits generated by the model prior to sampling. The exact + * effect will vary per model, but values between -1 and 1 should decrease or increase + * likelihood of selection; values like -100 or 100 should result in a ban or + * exclusive selection of the relevant token. + * @param logprobs Whether to return log probabilities of the output tokens or not. If + * true, returns the log probabilities of each output token returned in the 'content' + * of 'message'. + * @param topLogprobs An integer between 0 and 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 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 presencePenalty Number between -2.0 and 2.0. Positive values penalize new + * tokens based on whether they appear in the text so far, increasing the model's + * likelihood to talk about new topics. + * @param responseFormat An object specifying the format that the model must output. + * Setting to { "type": "json_object" } enables JSON mode, which guarantees the + * message the model generates is valid JSON. + * @param seed This feature is in Beta. If specified, our system will make a best + * effort to sample deterministically, such that repeated requests with the same seed + * 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 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 by a data: [DONE] message. + * @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 streamOptions Options for streaming response. Only set this when you set. - * @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 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 tools A list of tools the model may call. Currently, only functions are supported as a tool. Use this to - * provide a list of functions the model may generate JSON inputs for. - * @param toolChoice Controls which (if any) function is called by the model. none means the model will not call a - * function and instead generates a message. auto means the model can pick between generating a message or calling a - * function. Specifying a particular function via {"type: "function", "function": {"name": "my_function"}} forces - * the model to call that function. none is the default when no functions are present. auto is the default if - * functions are present. Use the {@link ToolChoiceBuilder} to create the tool choice value. - * @param parallelToolCalls If set to true, the model will call all functions in the tools list in parallel. If set - * to false, the model will call the functions in the tools list in the order they are provided. - * @param user A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. - * + * @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 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 tools A list of tools the model may call. Currently, only functions are + * supported as a tool. Use this to provide a list of functions the model may generate + * JSON inputs for. + * @param toolChoice Controls which (if any) function is called by the model. none + * means the model will not call a function and instead generates a message. auto + * means the model can pick between generating a message or calling a function. + * Specifying a particular function via {"type: "function", "function": {"name": + * "my_function"}} forces the model to call that function. none is the default when no + * functions are present. auto is the default if functions are present. Use the + * {@link ToolChoiceBuilder} to create the tool choice value. + * @param user A unique identifier representing your end-user, which can help OpenAI + * to monitor and detect abuse. + * @param parallelToolCalls If set to true, the model will call all functions in the + * tools list in parallel. Otherwise, the model will call the functions in the tools + * list in the order they are provided. */ @JsonInclude(Include.NON_NULL) - public record ChatCompletionRequest ( + public record ChatCompletionRequest(// @formatter:off @JsonProperty("messages") List messages, @JsonProperty("model") String model, @JsonProperty("frequency_penalty") Float frequencyPenalty, @@ -486,9 +525,9 @@ public class OpenAiApi { } /** - * @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 + * @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 * the choices field will always be an empty array. All other chunks * will also include a usage field, but with a null value. */ @@ -498,29 +537,30 @@ public class OpenAiApi { public static StreamOptions INCLUDE_USAGE = new StreamOptions(true); } - } + }// @formatter:on /** * Message comprising the conversation. * - * @param rawContent The contents of the message. Can be either a {@link MediaContent} or a {@link String}. - * The response message content is always a {@link String}. - * @param role The role of the messages author. Could be one of the {@link Role} types. - * @param name An optional name for the participant. Provides the model information to differentiate between - * participants of the same role. In case of Function calling, the name is the function name that the message is - * responding to. - * @param toolCallId Tool call that this message is responding to. Only applicable for the {@link Role#TOOL} role - * and null otherwise. - * @param toolCalls The tool calls generated by the model, such as function calls. Applicable only for - * {@link Role#ASSISTANT} role and null otherwise. + * @param rawContent The contents of the message. Can be either a {@link MediaContent} + * or a {@link String}. The response message content is always a {@link String}. + * @param role The role of the messages author. Could be one of the {@link Role} + * types. + * @param name An optional name for the participant. Provides the model information to + * differentiate between participants of the same role. In case of Function calling, + * the name is the function name that the message is responding to. + * @param toolCallId Tool call that this message is responding to. Only applicable for + * the {@link Role#TOOL} role and null otherwise. + * @param toolCalls The tool calls generated by the model, such as function calls. + * Applicable only for {@link Role#ASSISTANT} role and null otherwise. */ @JsonInclude(Include.NON_NULL) - public record ChatCompletionMessage( + public record ChatCompletionMessage(// @formatter:off @JsonProperty("content") Object rawContent, @JsonProperty("role") Role role, @JsonProperty("name") String name, @JsonProperty("tool_call_id") String toolCallId, - @JsonProperty("tool_calls") List toolCalls) { + @JsonProperty("tool_calls") List toolCalls) {// @formatter:on /** * Get message content as String. @@ -536,7 +576,8 @@ public class OpenAiApi { } /** - * Create a chat completion message with the given content and role. All other fields are null. + * Create a chat completion message with the given content and role. All other + * fields are null. * @param content The contents of the message. * @param role The role of the author of this message. */ @@ -548,50 +589,54 @@ public class OpenAiApi { * The role of the author of this message. */ public enum Role { + /** * System message. */ - @JsonProperty("system") SYSTEM, + @JsonProperty("system") + SYSTEM, /** * User message. */ - @JsonProperty("user") USER, + @JsonProperty("user") + USER, /** * Assistant message. */ - @JsonProperty("assistant") ASSISTANT, + @JsonProperty("assistant") + ASSISTANT, /** * Tool message. */ - @JsonProperty("tool") TOOL + @JsonProperty("tool") + TOOL + } /** - * An array of content parts with a defined type. - * Each MediaContent can be of either "text" or "image_url" type. Not both. + * An array of content parts with a defined type. Each MediaContent can be of + * either "text" or "image_url" type. Not both. * - * @param type Content type, each can be of type text or image_url. + * @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 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. */ @JsonInclude(Include.NON_NULL) - public record MediaContent( + public record MediaContent(// @formatter:off @JsonProperty("type") String type, @JsonProperty("text") String text, @JsonProperty("image_url") ImageUrl imageUrl) { - +// @formatter:on /** - * @param url Either a URL of the image or the base64 encoded image data. - * The base64 encoded image data must have a special prefix in the following format: - * "data:{mimetype};base64,{base64-encoded-image-data}". + * @param url Either a URL of the image or the base64 encoded image data. The + * base64 encoded image data must have a special prefix in the following + * format: "data:{mimetype};base64,{base64-encoded-image-data}". * @param detail Specifies the detail level of the image. */ @JsonInclude(Include.NON_NULL) - public record ImageUrl( - @JsonProperty("url") String url, - @JsonProperty("detail") String detail) { + public record ImageUrl(@JsonProperty("url") String url, @JsonProperty("detail") String detail) { public ImageUrl(String url) { this(url, null); @@ -614,93 +659,107 @@ public class OpenAiApi { this("image_url", null, imageUrl); } } + /** * 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 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( + public record ToolCall(// @formatter:off @JsonProperty("id") String id, @JsonProperty("type") String type, - @JsonProperty("function") ChatCompletionFunction function) { + @JsonProperty("function") ChatCompletionFunction function) {// @formatter:on } /** * The function definition. * * @param name The name of the function. - * @param arguments The arguments that the model expects you to pass to the function. + * @param arguments The arguments that the model expects you to pass to the + * function. */ @JsonInclude(Include.NON_NULL) - public record ChatCompletionFunction( + public record ChatCompletionFunction(// @formatter:off @JsonProperty("name") String name, - @JsonProperty("arguments") String arguments) { + @JsonProperty("arguments") String arguments) {// @formatter:on } } - public static String getTextContent(List content) { + public static String getTextContent(List content) { return content.stream() - .filter(c -> "text".equals(c.type())) - .map(ChatCompletionMessage.MediaContent::text) - .reduce("", (a, b) -> a + b); + .filter(c -> "text".equals(c.type())) + .map(ChatCompletionMessage.MediaContent::text) + .reduce("", (a, b) -> a + b); } /** * 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, + @JsonProperty("stop") + STOP, /** * The maximum number of tokens specified in the request was reached. */ - @JsonProperty("length") LENGTH, + @JsonProperty("length") + LENGTH, /** * The content was omitted due to a flag from our content filters. */ - @JsonProperty("content_filter") CONTENT_FILTER, + @JsonProperty("content_filter") + CONTENT_FILTER, /** * The model called a tool. */ - @JsonProperty("tool_calls") TOOL_CALLS, + @JsonProperty("tool_calls") + TOOL_CALLS, /** * (deprecated) The model called a function. */ - @JsonProperty("function_call") FUNCTION_CALL, + @JsonProperty("function_call") + FUNCTION_CALL, /** * Only for compatibility with Mistral AI API. */ - @JsonProperty("tool_call") TOOL_CALL + @JsonProperty("tool_call") + TOOL_CALL + } /** - * Represents a chat completion response returned by model, based on the provided input. + * Represents a chat completion response returned by model, based on the provided + * input. * * @param id A unique identifier for the chat completion. - * @param choices A list of chat completion choices. Can be more than one if n is greater than 1. - * @param created The Unix timestamp (in seconds) of when the chat completion was created. + * @param choices A list of chat completion choices. Can be more than one if n is + * greater than 1. + * @param created The Unix timestamp (in seconds) of when the chat completion was + * created. * @param model The model used for the chat completion. - * @param systemFingerprint This fingerprint represents the backend configuration that the model runs with. Can be - * used in conjunction with the seed request parameter to understand when backend changes have been made that might - * impact determinism. + * @param systemFingerprint This fingerprint represents the backend configuration that + * the model runs with. Can be used in conjunction with the seed request parameter to + * understand when backend changes have been made that might impact determinism. * @param object The object type, which is always chat.completion. * @param usage Usage statistics for the completion request. */ @JsonInclude(Include.NON_NULL) - public record ChatCompletion( + public record ChatCompletion(// @formatter:off @JsonProperty("id") String id, @JsonProperty("choices") List choices, @JsonProperty("created") Long created, @JsonProperty("model") String model, @JsonProperty("system_fingerprint") String systemFingerprint, @JsonProperty("object") String object, - @JsonProperty("usage") Usage usage) { + @JsonProperty("usage") Usage usage) {// @formatter:on /** * Chat completion choice. @@ -711,11 +770,11 @@ public class OpenAiApi { * @param logprobs Log probability information for the choice. */ @JsonInclude(Include.NON_NULL) - public record Choice( + public record Choice(// @formatter:off @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason, @JsonProperty("index") Integer index, @JsonProperty("message") ChatCompletionMessage message, - @JsonProperty("logprobs") LogProbs logprobs) { + @JsonProperty("logprobs") LogProbs logprobs) {// @formatter:on } } @@ -726,8 +785,7 @@ public class OpenAiApi { * @param content A list of message content tokens with log probability information. */ @JsonInclude(Include.NON_NULL) - public record LogProbs( - @JsonProperty("content") List content) { + public record LogProbs(@JsonProperty("content") List content) { /** * Message content tokens with log probability information. @@ -737,33 +795,35 @@ public class OpenAiApi { * @param probBytes A list of integers representing the UTF-8 bytes representation * of the token. Useful in instances where characters are represented by multiple * tokens and their byte representations must be combined to generate the correct - * text representation. Can be null if there is no bytes representation for the token. - * @param topLogprobs List of the most likely tokens and their log probability, - * at this token position. In rare cases, there may be fewer than the number of + * text representation. Can be null if there is no bytes representation for the + * token. + * @param topLogprobs List of the most likely tokens and their log probability, at + * this token position. In rare cases, there may be fewer than the number of * requested top_logprobs returned. */ @JsonInclude(Include.NON_NULL) - public record Content( + public record Content(// @formatter:off @JsonProperty("token") String token, @JsonProperty("logprob") Float logprob, @JsonProperty("bytes") List probBytes, - @JsonProperty("top_logprobs") List topLogprobs) { + @JsonProperty("top_logprobs") List topLogprobs) {// @formatter:on /** * The most likely tokens and their log probability, at this token position. * * @param token The token. * @param logprob The log probability of the token. - * @param probBytes A list of integers representing the UTF-8 bytes representation - * of the token. Useful in instances where characters are represented by multiple - * tokens and their byte representations must be combined to generate the correct - * text representation. Can be null if there is no bytes representation for the token. + * @param probBytes A list of integers representing the UTF-8 bytes + * representation of the token. Useful in instances where characters are + * represented by multiple tokens and their byte representations must be + * combined to generate the correct text representation. Can be null if there + * is no bytes representation for the token. */ @JsonInclude(Include.NON_NULL) - public record TopLogProbs( + public record TopLogProbs(// @formatter:off @JsonProperty("token") String token, @JsonProperty("logprob") Float logprob, - @JsonProperty("bytes") List probBytes) { + @JsonProperty("bytes") List probBytes) {// @formatter:on } } } @@ -771,40 +831,44 @@ public class OpenAiApi { /** * Usage statistics for the completion request. * - * @param completionTokens Number of tokens in the generated completion. Only applicable for completion requests. + * @param completionTokens Number of tokens in the generated completion. Only + * applicable for completion requests. * @param promptTokens Number of tokens in the prompt. - * @param totalTokens Total number of tokens used in the request (prompt + completion). + * @param totalTokens Total number of tokens used in the request (prompt + + * completion). */ @JsonInclude(Include.NON_NULL) - public record Usage( + public record Usage(// @formatter:off @JsonProperty("completion_tokens") Integer completionTokens, @JsonProperty("prompt_tokens") Integer promptTokens, - @JsonProperty("total_tokens") Integer totalTokens) { + @JsonProperty("total_tokens") Integer totalTokens) {// @formatter:on } /** - * Represents a streamed chunk of a chat completion response returned by model, based on the provided input. + * Represents a streamed chunk of a chat completion response returned by model, based + * on the provided input. * * @param id A unique identifier for the chat completion. Each chunk has the same ID. - * @param choices A list of chat completion choices. Can be more than one if n is greater than 1. - * @param created The Unix timestamp (in seconds) of when the chat completion was created. Each chunk has the same - * timestamp. + * @param choices A list of chat completion choices. Can be more than one if n is + * greater than 1. + * @param created The Unix timestamp (in seconds) of when the chat completion was + * created. Each chunk has the same timestamp. * @param model The model used for the chat completion. - * @param systemFingerprint This fingerprint represents the backend configuration that the model runs with. Can be - * used in conjunction with the seed request parameter to understand when backend changes have been made that might - * impact determinism. + * @param systemFingerprint This fingerprint represents the backend configuration that + * the model runs with. Can be used in conjunction with the seed request parameter to + * understand when backend changes have been made that might impact determinism. * @param object The object type, which is always 'chat.completion.chunk'. */ @JsonInclude(Include.NON_NULL) - public record ChatCompletionChunk( + 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) { + @JsonProperty("usage") Usage usage) {// @formatter:on /** * Chat completion choice. @@ -815,41 +879,69 @@ public class OpenAiApi { * @param logprobs Log probability information for the choice. */ @JsonInclude(Include.NON_NULL) - public record ChunkChoice( + public record ChunkChoice(// @formatter:off @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason, @JsonProperty("index") Integer index, @JsonProperty("delta") ChatCompletionMessage delta, - @JsonProperty("logprobs") LogProbs logprobs) { + @JsonProperty("logprobs") LogProbs logprobs) {// @formatter:on } } /** * Creates a model response for the given chat conversation. - * * @param chatRequest The chat completion request. - * @return Entity response with {@link ChatCompletion} as a body and HTTP status code and headers. + * @return Entity response with {@link ChatCompletion} as a body and HTTP status code + * and headers. */ public ResponseEntity chatCompletionEntity(ChatCompletionRequest chatRequest) { + return chatCompletionEntity(chatRequest, new LinkedMultiValueMap<>()); + } + + /** + * Creates a model response for the given chat conversation. + * @param chatRequest The chat completion request. + * @param additionalHttpHeader Optional, additional HTTP headers to be added to the + * request. + * @return Entity response with {@link ChatCompletion} as a body and HTTP status code + * and headers. + */ + public ResponseEntity chatCompletionEntity(ChatCompletionRequest chatRequest, + MultiValueMap additionalHttpHeader) { Assert.notNull(chatRequest, "The request body can not be null."); Assert.isTrue(!chatRequest.stream(), "Request must set the steam property to false."); + Assert.notNull(additionalHttpHeader, "The additional HTTP headers can not be null."); return this.restClient.post() - .uri(this.completionsPath) - .body(chatRequest) - .retrieve() - .toEntity(ChatCompletion.class); + .uri(this.completionsPath) + .headers(headers -> headers.addAll(additionalHttpHeader)) + .body(chatRequest) + .retrieve() + .toEntity(ChatCompletion.class); } private OpenAiStreamFunctionCallingHelper chunkMerger = new OpenAiStreamFunctionCallingHelper(); /** * Creates a streaming chat response for the given chat conversation. - * - * @param chatRequest The chat completion request. Must have the stream property set to true. + * @param chatRequest The chat completion request. Must have the stream property set + * to true. * @return Returns a {@link Flux} stream from chat completion chunks. */ public Flux chatCompletionStream(ChatCompletionRequest chatRequest) { + return chatCompletionStream(chatRequest, new LinkedMultiValueMap<>()); + } + + /** + * Creates a streaming chat response for the given chat conversation. + * @param chatRequest The chat completion request. Must have the stream property set + * to true. + * @param additionalHttpHeader Optional, additional HTTP headers to be added to the + * request. + * @return Returns a {@link Flux} stream from chat completion chunks. + */ + public Flux chatCompletionStream(ChatCompletionRequest chatRequest, + MultiValueMap additionalHttpHeader) { Assert.notNull(chatRequest, "The request body can not be null."); Assert.isTrue(chatRequest.stream(), "Request must set the steam property to true."); @@ -857,42 +949,44 @@ public class OpenAiApi { AtomicBoolean isInsideTool = new AtomicBoolean(false); return this.webClient.post() - .uri(this.completionsPath) - .body(Mono.just(chatRequest), ChatCompletionRequest.class) - .retrieve() - .bodyToFlux(String.class) - // cancels the flux stream after the "[DONE]" is received. - .takeUntil(SSE_DONE_PREDICATE) - // filters out the "[DONE]" message. - .filter(SSE_DONE_PREDICATE.negate()) - .map(content -> ModelOptionsUtils.jsonToObject(content, ChatCompletionChunk.class)) - // Detect is the chunk is part of a streaming function call. - .map(chunk -> { - if (this.chunkMerger.isStreamingToolFunctionCall(chunk)) { - isInsideTool.set(true); - } - return chunk; - }) - // Group all chunks belonging to the same function call. - // Flux -> Flux> - .windowUntil(chunk -> { - if (isInsideTool.get() && this.chunkMerger.isStreamingToolFunctionCallFinish(chunk)) { - isInsideTool.set(false); - return true; - } - return !isInsideTool.get(); - }) - // Merging the window chunks into a single chunk. - // Reduce the inner Flux window into a single Mono, - // Flux> -> Flux> - .concatMapIterable(window -> { - Mono monoChunk = window.reduce( - new ChatCompletionChunk(null, null, null, null, null, null, null), - (previous, current) -> this.chunkMerger.merge(previous, current)); - return List.of(monoChunk); - }) - // Flux> -> Flux - .flatMap(mono -> mono); + .uri(this.completionsPath) + .headers(headers -> headers.addAll(additionalHttpHeader)) + .body(Mono.just(chatRequest), ChatCompletionRequest.class) + .retrieve() + .bodyToFlux(String.class) + // cancels the flux stream after the "[DONE]" is received. + .takeUntil(SSE_DONE_PREDICATE) + // filters out the "[DONE]" message. + .filter(SSE_DONE_PREDICATE.negate()) + .map(content -> ModelOptionsUtils.jsonToObject(content, ChatCompletionChunk.class)) + // Detect is the chunk is part of a streaming function call. + .map(chunk -> { + if (this.chunkMerger.isStreamingToolFunctionCall(chunk)) { + isInsideTool.set(true); + } + return chunk; + }) + // Group all chunks belonging to the same function call. + // Flux -> Flux> + .windowUntil(chunk -> { + if (isInsideTool.get() && this.chunkMerger.isStreamingToolFunctionCallFinish(chunk)) { + isInsideTool.set(false); + return true; + } + return !isInsideTool.get(); + }) + // Merging the window chunks into a single chunk. + // Reduce the inner Flux window into a single + // Mono, + // Flux> -> Flux> + .concatMapIterable(window -> { + Mono monoChunk = window.reduce( + new ChatCompletionChunk(null, null, null, null, null, null, null), + (previous, current) -> this.chunkMerger.merge(previous, current)); + return List.of(monoChunk); + }) + // Flux> -> Flux + .flatMap(mono -> mono); } // Embeddings API @@ -904,25 +998,23 @@ public class OpenAiApi { public enum EmbeddingModel { /** - * Most capable embedding model for both english and non-english tasks. - * DIMENSION: 3072 + * Most capable embedding model for both english and non-english tasks. DIMENSION: + * 3072 */ TEXT_EMBEDDING_3_LARGE("text-embedding-3-large"), /** - * Increased performance over 2nd generation ada embedding model. - * DIMENSION: 1536 + * Increased performance over 2nd generation ada embedding model. DIMENSION: 1536 */ TEXT_EMBEDDING_3_SMALL("text-embedding-3-small"), /** - * Most capable 2nd generation embedding model, replacing 16 first - * generation models. - * DIMENSION: 1536 + * Most capable 2nd generation embedding model, replacing 16 first generation + * models. DIMENSION: 1536 */ TEXT_EMBEDDING_ADA_002("text-embedding-ada-002"); - public final String value; + public final String value; EmbeddingModel(String value) { this.value = value; @@ -931,26 +1023,29 @@ public class OpenAiApi { public String getValue() { return value; } + } /** * Represents an embedding vector returned by embedding endpoint. * * @param index The index of the embedding in the list of embeddings. - * @param embedding The embedding vector, which is a list of floats. The length of vector depends on the model. + * @param embedding The embedding vector, which is a list of floats. The length of + * vector depends on the model. * @param object The object type, which is always 'embedding'. */ @JsonInclude(Include.NON_NULL) - public record Embedding( + public record Embedding(// @formatter:off @JsonProperty("index") Integer index, @JsonProperty("embedding") List embedding, - @JsonProperty("object") String object) { + @JsonProperty("object") String object) {// @formatter:on /** - * Create an embedding with the given index, embedding and object type set to 'embedding'. - * + * Create an embedding with the given index, embedding and object type set to + * 'embedding'. * @param index The index of the embedding in the list of embeddings. - * @param embedding The embedding vector, which is a list of floats. The length of vector depends on the model. + * @param embedding The embedding vector, which is a list of floats. The length of + * vector depends on the model. */ public Embedding(Integer index, List embedding) { this(index, embedding, "embedding"); @@ -960,25 +1055,30 @@ public class OpenAiApi { /** * Creates an embedding vector representing the input text. * - * @param input Input text to embed, encoded as a string or array of tokens. To embed multiple inputs in a single - * request, pass an array of strings or array of token arrays. The input must not exceed the max input tokens for - * the model (8192 tokens for text-embedding-ada-002), cannot be an empty string, and any array must be 2048 + * @param input Input text to embed, encoded as a string or array of tokens. To embed + * multiple inputs in a single request, pass an array of strings or array of token + * arrays. The input must not exceed the max input tokens for the model (8192 tokens + * for text-embedding-ada-002), cannot be an empty string, and any array must be 2048 * dimensions or less. * @param model ID of the model to use. - * @param encodingFormat The format to return the embeddings in. Can be either float or base64. - * @param dimensions The number of dimensions the resulting output embeddings should have. Only supported in text-embedding-3 and later models. - * @param user A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. + * @param encodingFormat The format to return the embeddings in. Can be either float + * or base64. + * @param dimensions The number of dimensions the resulting output embeddings should + * have. Only supported in text-embedding-3 and later models. + * @param user A unique identifier representing your end-user, which can help OpenAI + * to monitor and detect abuse. */ @JsonInclude(Include.NON_NULL) - public record EmbeddingRequest( + public record EmbeddingRequest(// @formatter:off @JsonProperty("input") T input, @JsonProperty("model") String model, @JsonProperty("encoding_format") String encodingFormat, @JsonProperty("dimensions") Integer dimensions, - @JsonProperty("user") String user) { + @JsonProperty("user") String user) {// @formatter:on /** - * Create an embedding request with the given input, model and encoding format set to float. + * Create an embedding request with the given input, model and encoding format set + * to float. * @param input Input text to embed. * @param model ID of the model to use. */ @@ -987,8 +1087,8 @@ public class OpenAiApi { } /** - * Create an embedding request with the given input. Encoding format is set to float and user is null and the - * model is set to 'text-embedding-ada-002'. + * Create an embedding request with the given input. Encoding format is set to + * float and user is null and the model is set to 'text-embedding-ada-002'. * @param input Input text to embed. */ public EmbeddingRequest(T input) { @@ -1006,21 +1106,21 @@ public class OpenAiApi { * @param usage Usage statistics for the completion request. */ @JsonInclude(Include.NON_NULL) - public record EmbeddingList( + public record EmbeddingList(// @formatter:off @JsonProperty("object") String object, @JsonProperty("data") List data, @JsonProperty("model") String model, - @JsonProperty("usage") Usage usage) { + @JsonProperty("usage") Usage usage) {// @formatter:on } /** * Creates an embedding vector representing the input text or token array. - * * @param embeddingRequest The embedding request. * @return Returns list of {@link Embedding} wrapped in {@link EmbeddingList}. - * @param Type of the entity in the data list. Can be a {@link String} or {@link List} of tokens (e.g. - * Integers). For embedding multiple inputs in a single request, You can pass a {@link List} of {@link String} or - * {@link List} of {@link List} of tokens. For example: + * @param Type of the entity in the data list. Can be a {@link String} or + * {@link List} of tokens (e.g. Integers). For embedding multiple inputs in a single + * request, You can pass a {@link List} of {@link String} or {@link List} of + * {@link List} of tokens. For example: * *
{@code List.of("text1", "text2", "text3") or List.of(List.of(1, 2, 3), List.of(3, 4, 5))} 
*/ @@ -1028,29 +1128,30 @@ public class OpenAiApi { Assert.notNull(embeddingRequest, "The request body can not be null."); - // Input text to embed, encoded as a string or array of tokens. To embed multiple inputs in a single + // Input text to embed, encoded as a string or array of tokens. To embed multiple + // inputs in a single // request, pass an array of strings or array of token arrays. Assert.notNull(embeddingRequest.input(), "The input can not be null."); Assert.isTrue(embeddingRequest.input() instanceof String || embeddingRequest.input() instanceof List, "The input must be either a String, or a List of Strings or List of List of integers."); - // The input must not exceed the max input tokens for the model (8192 tokens for text-embedding-ada-002), cannot + // The input must not exceed the max input tokens for the model (8192 tokens for + // text-embedding-ada-002), cannot // be an empty string, and any array must be 2048 dimensions or less. if (embeddingRequest.input() instanceof List list) { Assert.isTrue(!CollectionUtils.isEmpty(list), "The input list can not be empty."); Assert.isTrue(list.size() <= 2048, "The list must be 2048 dimensions or less"); - Assert.isTrue(list.get(0) instanceof String || list.get(0) instanceof Integer - || list.get(0) instanceof List, + Assert.isTrue( + list.get(0) instanceof String || list.get(0) instanceof Integer || list.get(0) instanceof List, "The input must be either a String, or a List of Strings or list of list of integers."); } return this.restClient.post() - .uri(this.embeddingsPath) - .body(embeddingRequest) - .retrieve() - .toEntity(new ParameterizedTypeReference<>() { - }); + .uri(this.embeddingsPath) + .body(embeddingRequest) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); } } -// @formatter:on diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiAudioApi.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiAudioApi.java index 77f4eb70e..e8b4fdaea 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiAudioApi.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiAudioApi.java @@ -16,6 +16,7 @@ package org.springframework.ai.openai.api; import java.util.List; +import java.util.Map; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; @@ -29,6 +30,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.ResponseErrorHandler; @@ -81,21 +83,48 @@ public class OpenAiAudioApi { /** * Create an new chat completion api. * @param baseUrl api base URL. - * @param openAiToken OpenAI apiKey. + * @param apiKey OpenAI apiKey. * @param restClientBuilder RestClient builder. * @param webClientBuilder WebClient builder. * @param responseErrorHandler Response error handler. */ - public OpenAiAudioApi(String baseUrl, String openAiToken, RestClient.Builder restClientBuilder, + public OpenAiAudioApi(String baseUrl, String apiKey, RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler) { - this.restClient = restClientBuilder.baseUrl(baseUrl).defaultHeaders(headers -> { - headers.setBearerAuth(openAiToken); - }).defaultStatusHandler(responseErrorHandler).build(); + this(baseUrl, apiKey, CollectionUtils.toMultiValueMap(Map.of()), restClientBuilder, webClientBuilder, + responseErrorHandler); + } - this.webClient = webClientBuilder.baseUrl(baseUrl).defaultHeaders(headers -> { - headers.setBearerAuth(openAiToken); - }).defaultHeaders(ApiUtils.getJsonContentHeaders(openAiToken)).build(); + /** + * Create an new chat completion api. + * @param baseUrl api base URL. + * @param apiKey OpenAI apiKey. + * @param headers the http headers to use. + * @param restClientBuilder RestClient builder. + * @param webClientBuilder WebClient builder. + * @param responseErrorHandler Response error handler. + */ + public OpenAiAudioApi(String baseUrl, String apiKey, MultiValueMap headers, + RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, + ResponseErrorHandler responseErrorHandler) { + + // @formatter:off + this.restClient = restClientBuilder + .baseUrl(baseUrl) + .defaultHeaders(h -> { + h.setBearerAuth(apiKey); + h.addAll(headers); + }) + .defaultStatusHandler(responseErrorHandler).build(); + + this.webClient = webClientBuilder + .baseUrl(baseUrl) + .defaultHeaders(h -> { + h.setBearerAuth(apiKey); + h.addAll(headers); + }) + .defaultHeaders(ApiUtils.getJsonContentHeaders(apiKey)).build(); + // @formatter:on } /** diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiImageApi.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiImageApi.java index f508939b2..698bbbae5 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiImageApi.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiImageApi.java @@ -16,18 +16,21 @@ package org.springframework.ai.openai.api; import java.util.List; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; import org.springframework.ai.openai.api.common.OpenAiApiConstants; import org.springframework.ai.retry.RetryUtils; -import org.springframework.ai.util.api.ApiUtils; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MultiValueMap; import org.springframework.web.client.ResponseErrorHandler; import org.springframework.web.client.RestClient; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + /** * OpenAI Image API. * @@ -60,17 +63,36 @@ public class OpenAiImageApi { /** * Create a new OpenAI Image API with the provided base URL. * @param baseUrl the base URL for the OpenAI API. - * @param openAiToken OpenAI apiKey. + * @param apiKey OpenAI apiKey. * @param restClientBuilder the rest client builder to use. * @param responseErrorHandler the response error handler to use. */ - public OpenAiImageApi(String baseUrl, String openAiToken, RestClient.Builder restClientBuilder, + public OpenAiImageApi(String baseUrl, String apiKey, RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { + this(baseUrl, apiKey, CollectionUtils.toMultiValueMap(Map.of()), restClientBuilder, responseErrorHandler); + } + /** + * Create a new OpenAI Image API with the provided base URL. + * @param baseUrl the base URL for the OpenAI API. + * @param apiKey OpenAI apiKey. + * @param headers the http headers to use. + * @param restClientBuilder the rest client builder to use. + * @param responseErrorHandler the response error handler to use. + */ + public OpenAiImageApi(String baseUrl, String apiKey, MultiValueMap headers, + RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { + + // @formatter:off this.restClient = restClientBuilder.baseUrl(baseUrl) - .defaultHeaders(ApiUtils.getJsonContentHeaders(openAiToken)) + .defaultHeaders(h -> { + h.setBearerAuth(apiKey); + h.setContentType(MediaType.APPLICATION_JSON); + h.addAll(headers); + }) .defaultStatusHandler(responseErrorHandler) .build(); + // @formatter:on } /** 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 cf8a50ca6..778870c3a 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 @@ -42,6 +42,7 @@ import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionChunk; import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest; import org.springframework.http.ResponseEntity; import org.springframework.util.MimeTypeUtils; +import org.springframework.util.MultiValueMap; import reactor.core.publisher.Flux; @@ -58,7 +59,10 @@ public class MessageTypeContentTests { OpenAiChatModel chatModel; @Captor - ArgumentCaptor promptCaptor; + ArgumentCaptor pomptCaptor; + + @Captor + ArgumentCaptor> headersCaptor; Flux fluxResponse = Flux .generate(() -> new ChatCompletionChunk("id", List.of(), 0l, "model", "fp", "object", null), (state, sink) -> { @@ -75,31 +79,35 @@ public class MessageTypeContentTests { @Test public void systemMessageSimpleContentType() { - when(openAiApi.chatCompletionEntity(promptCaptor.capture())).thenReturn(Mockito.mock(ResponseEntity.class)); + when(openAiApi.chatCompletionEntity(pomptCaptor.capture(), headersCaptor.capture())) + .thenReturn(Mockito.mock(ResponseEntity.class)); chatModel.call(new Prompt(List.of(new SystemMessage("test message")))); - validateStringContent(promptCaptor.getValue()); + validateStringContent(pomptCaptor.getValue()); + assertThat(headersCaptor.getValue()).isEmpty(); } @Test public void userMessageSimpleContentType() { - when(openAiApi.chatCompletionEntity(promptCaptor.capture())).thenReturn(Mockito.mock(ResponseEntity.class)); + when(openAiApi.chatCompletionEntity(pomptCaptor.capture(), headersCaptor.capture())) + .thenReturn(Mockito.mock(ResponseEntity.class)); chatModel.call(new Prompt(List.of(new UserMessage("test message")))); - validateStringContent(promptCaptor.getValue()); + validateStringContent(pomptCaptor.getValue()); } @Test public void streamUserMessageSimpleContentType() { - when(openAiApi.chatCompletionStream(promptCaptor.capture())).thenReturn(fluxResponse); + when(openAiApi.chatCompletionStream(pomptCaptor.capture(), headersCaptor.capture())).thenReturn(fluxResponse); chatModel.stream(new Prompt(List.of(new UserMessage("test message")))); - validateStringContent(promptCaptor.getValue()); + validateStringContent(pomptCaptor.getValue()); + assertThat(headersCaptor.getValue()).isEmpty(); } private void validateStringContent(ChatCompletionRequest chatCompletionRequest) { @@ -113,25 +121,26 @@ public class MessageTypeContentTests { @Test public void userMessageWithMediaType() throws MalformedURLException { - when(openAiApi.chatCompletionEntity(promptCaptor.capture())).thenReturn(Mockito.mock(ResponseEntity.class)); + when(openAiApi.chatCompletionEntity(pomptCaptor.capture(), headersCaptor.capture())) + .thenReturn(Mockito.mock(ResponseEntity.class)); URL mediaUrl = new URL("http://test"); chatModel.call(new Prompt( List.of(new UserMessage("test message", List.of(new Media(MimeTypeUtils.IMAGE_JPEG, mediaUrl)))))); - validateComplexContent(promptCaptor.getValue()); + validateComplexContent(pomptCaptor.getValue()); } @Test public void streamUserMessageWithMediaType() throws MalformedURLException { - when(openAiApi.chatCompletionStream(promptCaptor.capture())).thenReturn(fluxResponse); + when(openAiApi.chatCompletionStream(pomptCaptor.capture(), headersCaptor.capture())).thenReturn(fluxResponse); URL mediaUrl = new URL("http://test"); chatModel.stream(new Prompt( List.of(new UserMessage("test message", List.of(new Media(MimeTypeUtils.IMAGE_JPEG, mediaUrl)))))); - validateComplexContent(promptCaptor.getValue()); + validateComplexContent(pomptCaptor.getValue()); } private void validateComplexContent(ChatCompletionRequest chatCompletionRequest) { diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatModeAdditionalHttpHeadersIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatModeAdditionalHttpHeadersIT.java new file mode 100644 index 000000000..7bb2a9813 --- /dev/null +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatModeAdditionalHttpHeadersIT.java @@ -0,0 +1,79 @@ +/* + * Copyright 2024 - 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.openai.chat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertThrows; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.retry.NonTransientAiException; +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; + +/** + * @author Christian Tzolov + */ +@SpringBootTest(classes = OpenAiChatModeAdditionalHttpHeadersIT.Config.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +public class OpenAiChatModeAdditionalHttpHeadersIT { + + @Autowired + private OpenAiChatModel openAiChatModel; + + @Test + void additionalApiKeyHeader() { + + assertThrows(NonTransientAiException.class, () -> { + this.openAiChatModel.call("Tell me a joke"); + }); + + // Use the additional headers to override the Api Key. + // Mind that you have to prefix the Api Key with the "Bearer " prefix. + OpenAiChatOptions options = OpenAiChatOptions.builder() + .withHttpHeaders(Map.of("Authorization", "Bearer " + System.getenv("OPENAI_API_KEY"))) + .build(); + + ChatResponse response = this.openAiChatModel.call(new Prompt("Tell me a joke", options)); + + assertThat(response).isNotNull(); + } + + @SpringBootConfiguration + static class Config { + + @Bean + public OpenAiApi chatCompletionApi() { + return new OpenAiApi("Invalid API Key"); + } + + @Bean + public OpenAiChatModel openAiClient(OpenAiApi openAiApi) { + return new OpenAiChatModel(openAiApi); + } + + } + +} 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 fa998f44e..c7d510af5 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 @@ -69,6 +69,7 @@ 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.ArgumentMatchers.any; import static org.mockito.Mockito.when; /** @@ -141,7 +142,7 @@ public class OpenAiRetryTests { ChatCompletion expectedChatCompletion = new ChatCompletion("id", List.of(choice), 666l, "model", null, null, new OpenAiApi.Usage(10, 10, 10)); - when(openAiApi.chatCompletionEntity(isA(ChatCompletionRequest.class))) + when(openAiApi.chatCompletionEntity(isA(ChatCompletionRequest.class), any())) .thenThrow(new TransientAiException("Transient Error 1")) .thenThrow(new TransientAiException("Transient Error 2")) .thenReturn(ResponseEntity.of(Optional.of(expectedChatCompletion))); @@ -156,8 +157,8 @@ public class OpenAiRetryTests { @Test public void openAiChatNonTransientError() { - when(openAiApi.chatCompletionEntity(isA(ChatCompletionRequest.class))) - .thenThrow(new RuntimeException("Non Transient Error")); + when(openAiApi.chatCompletionEntity(isA(ChatCompletionRequest.class), any())) + .thenThrow(new RuntimeException("Non Transient Error")); assertThrows(RuntimeException.class, () -> chatModel.call(new Prompt("text"))); } @@ -169,7 +170,7 @@ public class OpenAiRetryTests { ChatCompletionChunk expectedChatCompletion = new ChatCompletionChunk("id", List.of(choice), 666l, "model", null, null, null); - when(openAiApi.chatCompletionStream(isA(ChatCompletionRequest.class))) + when(openAiApi.chatCompletionStream(isA(ChatCompletionRequest.class), any())) .thenThrow(new TransientAiException("Transient Error 1")) .thenThrow(new TransientAiException("Transient Error 2")) .thenReturn(Flux.just(expectedChatCompletion)); @@ -184,8 +185,8 @@ public class OpenAiRetryTests { @Test public void openAiChatStreamNonTransientError() { - when(openAiApi.chatCompletionStream(isA(ChatCompletionRequest.class))) - .thenThrow(new RuntimeException("Non Transient Error")); + when(openAiApi.chatCompletionStream(isA(ChatCompletionRequest.class), any())) + .thenThrow(new RuntimeException("Non Transient Error")); assertThrows(RuntimeException.class, () -> chatModel.stream(new Prompt("text"))); } @@ -210,10 +211,9 @@ public class OpenAiRetryTests { @Test public void openAiEmbeddingNonTransientError() { - when(openAiApi.embeddings(isA(EmbeddingRequest.class))) - .thenThrow(new RuntimeException("Non Transient Error")); + when(openAiApi.embeddings(isA(EmbeddingRequest.class))).thenThrow(new RuntimeException("Non Transient Error")); assertThrows(RuntimeException.class, () -> embeddingModel - .call(new org.springframework.ai.embedding.EmbeddingRequest(List.of("text1", "text2"), null))); + .call(new org.springframework.ai.embedding.EmbeddingRequest(List.of("text1", "text2"), null))); } @Test @@ -238,9 +238,9 @@ public class OpenAiRetryTests { @Test public void openAiAudioTranscriptionNonTransientError() { when(openAiAudioApi.createTranscription(isA(TranscriptionRequest.class), isA(Class.class))) - .thenThrow(new RuntimeException("Transient Error 1")); + .thenThrow(new RuntimeException("Transient Error 1")); assertThrows(RuntimeException.class, () -> audioTranscriptionModel - .call(new AudioTranscriptionPrompt(new ClassPathResource("speech/jfk.flac")))); + .call(new AudioTranscriptionPrompt(new ClassPathResource("speech/jfk.flac")))); } @Test @@ -264,7 +264,7 @@ public class OpenAiRetryTests { @Test public void openAiImageNonTransientError() { when(openAiImageApi.createImage(isA(OpenAiImageRequest.class))) - .thenThrow(new RuntimeException("Transient Error 1")); + .thenThrow(new RuntimeException("Transient Error 1")); assertThrows(RuntimeException.class, () -> imageModel.call(new ImagePrompt(List.of(new ImageMessage("Image Message"))))); } diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/audio/speech/openai-speech.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/audio/speech/openai-speech.adoc index 6ba1841ac..4a5511735 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/audio/speech/openai-speech.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/audio/speech/openai-speech.adoc @@ -1,4 +1,4 @@ -= OpenAI Text-to-Speech (TTS) Integration += OpenAI Text-to-Speech (TTS) == Introduction @@ -37,7 +37,25 @@ dependencies { TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. -=== TTS Properties +== Speech Properties + +=== Connection Properties + +The prefix `spring.ai.openai` is used as the property prefix that lets you connect to OpenAI. + +[cols="3,5,1"] +|==== +| Property | Description | Default +| spring.ai.openai.base-url | The URL to connect to | https://api.openai.com +| spring.ai.openai.api-key | The API Key | - +| spring.ai.openai.organization-id | Optionally you can specify which organization used for an API request. | - +| spring.ai.openai.project-id | Optionally, you can specify which project is used for an API request. | - +|==== + +TIP: For users that belong to multiple organizations (or are accessing their projects through their legacy user API key), optionally, you can specify which organization and project is used for an API request. +Usage from these API requests will count as usage for the specified organization and project. + +=== Configuraiton Properties The prefix `spring.ai.openai.audio.speech` is used as the property prefix that lets you configure the OpenAI Text-to-Speech client. @@ -45,12 +63,22 @@ The prefix `spring.ai.openai.audio.speech` is used as the property prefix that l |==== | Property | Description | Default +| spring.ai.openai.audio.speech.base-url | The URL to connect to | https://api.openai.com +| spring.ai.openai.audio.speech.api-key | The API Key | - +| spring.ai.openai.audio.speech.organization-id | Optionally you can specify which organization used for an API request. | - +| spring.ai.openai.audio.speech.project-id | Optionally, you can specify which project is used for an API request. | - | spring.ai.openai.audio.speech.options.model | ID of the model to use. Only tts-1 is currently available. | tts-1 | spring.ai.openai.audio.speech.options.voice | The voice to use for the TTS output. Available options are: alloy, echo, fable, onyx, nova, and shimmer. | alloy | spring.ai.openai.audio.speech.options.response-format | The format of the audio output. Supported formats are mp3, opus, aac, flac, wav, and pcm. | mp3 | spring.ai.openai.audio.speech.options.speed | The speed of the voice synthesis. The acceptable range is from 0.0 (slowest) to 1.0 (fastest). | 1.0 |==== +NOTE: You can override the common `spring.ai.openai.base-url`, `spring.ai.openai.api-key`, `spring.ai.openai.organization-id` and `spring.ai.openai.project-id` properties. +The `spring.ai.openai.audio.speech.base-url`, `spring.ai.openai.audio.speech.api-key`, `spring.ai.openai.audio.speech.organization-id` and `spring.ai.openai.audio.speech.project-id` properties if set take precedence over the common properties. +This is useful if you want to use different OpenAI accounts for different models and different model endpoints. + +TIP: All properties prefixed with `spring.ai.openai.image.options` can be overridden at runtime. + == Runtime Options [[speech-options]] The `OpenAiAudioSpeechOptions` class provides the options to use when making a text-to-speech request. diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/audio/transcriptions/openai-transcriptions.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/audio/transcriptions/openai-transcriptions.adoc index 822426c55..a785d3ef1 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/audio/transcriptions/openai-transcriptions.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/audio/transcriptions/openai-transcriptions.adoc @@ -1,20 +1,22 @@ -= OpenAI Transcriptions +== OpenAI Transcriptions Spring AI supports https://platform.openai.com/docs/api-reference/audio/createTranscription[OpenAI's Transcription model]. == Prerequisites + You will need to create an API key with OpenAI to access ChatGPT models. Create an account at https://platform.openai.com/signup[OpenAI signup page] and generate the token on the https://platform.openai.com/account/api-keys[API Keys page]. The Spring AI project defines a configuration property named `spring.ai.openai.api-key` that you should set to the value of the `API Key` obtained from openai.com. Exporting an environment variable is one way to set that configuration property: + == Auto-configuration Spring AI provides Spring Boot auto-configuration for the OpenAI Image Generation Client. -To enable it, add the following dependency to your project's Maven `pom.xml` file: +To enable it add the following dependency to your project's Maven `pom.xml` file: -[source,xml] +[source, xml] ---- org.springframework.ai @@ -35,12 +37,34 @@ TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Man === Transcription Properties +==== Connection Properties + +The prefix `spring.ai.openai` is used as the property prefix that lets you connect to OpenAI. + +[cols="3,5,1"] +|==== +| Property | Description | Default +| spring.ai.openai.base-url | The URL to connect to | https://api.openai.com +| spring.ai.openai.api-key | The API Key | - +| spring.ai.openai.organization-id | Optionally you can specify which organization used for an API request. | - +| spring.ai.openai.project-id | Optionally, you can specify which project is used for an API request. | - +|==== + +TIP: For users that belong to multiple organizations (or are accessing their projects through their legacy user API key), optionally, you can specify which organization and project is used for an API request. +Usage from these API requests will count as usage for the specified organization and project. + +==== Configuraiton Properties + The prefix `spring.ai.openai.audio.transcription` is used as the property prefix that lets you configure the retry mechanism for the OpenAI image model. [cols="3,5,2"] |==== | Property | Description | Default +| spring.ai.openai.audio.transcription.base-url | The URL to connect to | https://api.openai.com +| spring.ai.openai.audio.transcription.api-key | The API Key | - +| spring.ai.openai.audio.transcription.organization-id | Optionally you can specify which organization used for an API request. | - +| spring.ai.openai.audio.transcription.project-id | Optionally, you can specify which project is used for an API request. | - | spring.ai.openai.audio.transcription.options.model | ID of the model to use. Only whisper-1 (which is powered by our open source Whisper V2 model) is currently available. | whisper-1 | spring.ai.openai.audio.transcription.options.response-format | The format of the transcript output, in one of these options: json, text, srt, verbose_json, or vtt. | json | spring.ai.openai.audio.transcription.options.prompt | An optional text to guide the model's style or continue a previous audio segment. The prompt should match the audio language. | @@ -49,10 +73,16 @@ The prefix `spring.ai.openai.audio.transcription` is used as the property prefix | spring.ai.openai.audio.transcription.options.timestamp_granularities | The timestamp granularities to populate for this transcription. response_format must be set verbose_json to use timestamp granularities. Either or both of these options are supported: word, or segment. Note: There is no additional latency for segment timestamps, but generating word timestamps incurs additional latency. | segment |==== +NOTE: You can override the common `spring.ai.openai.base-url`, `spring.ai.openai.api-key`, `spring.ai.openai.organization-id` and `spring.ai.openai.project-id` properties. +The `spring.ai.openai.audio.transcription.base-url`, `spring.ai.openai.audio.transcription.api-key`, `spring.ai.openai.audio.transcription.organization-id` and `spring.ai.openai.audio.transcription.project-id` properties if set take precedence over the common properties. +This is useful if you want to use different OpenAI accounts for different models and different model endpoints. + +TIP: All properties prefixed with `spring.ai.openai.image.options` can be overridden at runtime. + == Runtime Options [[image-options]] The `OpenAiAudioTranscriptionOptions` class provides the options to use when making a transcription. -On start-up, the options specified by `spring.ai.openai.audio.transcription` are used, but you can override these at runtime. +On start-up, the options specified by `spring.ai.openai.audio.transcription` are used but you can override these at runtime. For example: @@ -74,7 +104,7 @@ AudioTranscriptionResponse response = openAiTranscriptionModel.call(transcriptio Add the `spring-ai-openai` dependency to your project's Maven `pom.xml` file: -[source,xml] +[source, xml] ---- org.springframework.ai @@ -113,5 +143,4 @@ AudioTranscriptionResponse response = openAiTranscriptionModel.call(transcriptio ---- == Example Code - -* The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/transcription/OpenAiTranscriptionModelIT.java[OpenAiTranscriptionModelIT.java] test provides some general examples how to use the library. +* The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/transcription/OpenAiTranscriptionModelIT.java[OpenAiTranscriptionModelIT.java] test provides some general examples how to use the library. \ No newline at end of file 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 4ff7debcb..6de85abb0 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 @@ -74,10 +74,14 @@ The prefix `spring.ai.openai` is used as the property prefix that lets you conne |==== | Property | Description | Default -| spring.ai.openai.base-url | The URL to connect to | https://api.openai.com -| spring.ai.openai.api-key | The API Key | - +| spring.ai.openai.base-url | The URL to connect to | https://api.openai.com +| spring.ai.openai.api-key | The API Key | - +| spring.ai.openai.organization-id | Optionally you can specify which organization used for an API request. | - +| spring.ai.openai.project-id | Optionally, you can specify which project is used for an API request. | - |==== +TIP: For users that belong to multiple organizations (or are accessing their projects through their legacy user API key), optionally, you can specify which organization and project is used for an API request. +Usage from these API requests will count as usage for the specified organization and project. ==== Configuration Properties @@ -91,6 +95,8 @@ The prefix `spring.ai.openai.chat` is the property prefix that lets you configur | spring.ai.openai.chat.base-url | Optional overrides the spring.ai.openai.base-url to provide chat specific url | - | spring.ai.openai.chat.completions-path | The path to append to the base-url | `/v1/chat/completions` | spring.ai.openai.chat.api-key | Optional overrides the spring.ai.openai.api-key to provide chat specific api-key | - +| spring.ai.openai.chat.organization-id | Optionally you can specify which organization used for an API request. | - +| spring.ai.openai.chat.project-id | Optionally, you can specify which project is used for an API request. | - | spring.ai.openai.chat.options.model | This is the OpenAI Chat model to use. `gpt-4o`, `gpt-4-turbo`, `gpt-4-turbo-2024-04-09`, `gpt-4-0125-preview`, `gpt-4-turbo-preview`, `gpt-3.5-turbo`, `gpt-3.5-turbo-0125`, `gpt-3.5-turbo-1106`. See the https://platform.openai.com/docs/models[models] page for more information. | `gpt-3.5-turbo` | spring.ai.openai.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.8 | spring.ai.openai.chat.options.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. | 0.0f @@ -108,6 +114,7 @@ The prefix `spring.ai.openai.chat` is the property prefix that lets you configur | spring.ai.openai.chat.options.functions | List of functions, identified by their names, to enable for function calling in a single prompt requests. Functions with those names must exist in the functionCallbacks registry. | - | spring.ai.openai.chat.options.stream-usage | (For streaming only) Set to add an additional chunk with token usage statistics for the entire request. The `choices` field for this chunk is an empty array and all other chunks will also include a usage field, but with a null value. | false | spring.ai.openai.chat.options.parallel-tool-calls | Whether to enable link:https://platform.openai.com/docs/guides/function-calling/parallel-function-calling[parallel function calling] during tool use. | true +| spring.ai.openai.chat.options.http-headers | Optional HTTP headers to be added to the chat completion request. To override the api-key you need to use a `Authorization` header key and you have to prefix the key value with the `Bearer ` prefix. | - |==== NOTE: You can override the common `spring.ai.openai.base-url` and `spring.ai.openai.api-key` for the `ChatModel` and `EmbeddingModel` implementations. diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/openai-embeddings.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/openai-embeddings.adoc index 1e2db6c71..7e66fd82f 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/openai-embeddings.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/openai-embeddings.adoc @@ -77,8 +77,13 @@ The prefix `spring.ai.openai` is used as the property prefix that lets you conne | spring.ai.openai.base-url | The URL to connect to | +https://api.openai.com+ | spring.ai.openai.api-key | The API Key | - +| spring.ai.openai.organization-id | Optionally you can specify which organization used for an API request. | - +| spring.ai.openai.project-id | Optionally, you can specify which project is used for an API request. | - |==== +TIP: For users that belong to multiple organizations (or are accessing their projects through their legacy user API key), optionally, you can specify which organization and project is used for an API request. +Usage from these API requests will count as usage for the specified organization and project. + ==== Configuration Properties The prefix `spring.ai.openai.embedding` is property prefix that configures the `EmbeddingModel` implementation for OpenAI. @@ -91,6 +96,8 @@ The prefix `spring.ai.openai.embedding` is property prefix that configures the ` | spring.ai.openai.embedding.base-url | Optional overrides the spring.ai.openai.base-url to provide embedding specific url | - | spring.ai.openai.chat.embeddings-path | The path to append to the base-url | `/v1/embeddings` | spring.ai.openai.embedding.api-key | Optional overrides the spring.ai.openai.api-key to provide embedding specific api-key | - +| spring.ai.openai.embedding.organization-id | Optionally you can specify which organization used for an API request. | - +| spring.ai.openai.embedding.project-id | Optionally, you can specify which project is used for an API request. | - | spring.ai.openai.embedding.metadata-mode | Document content extraction mode. | EMBED | spring.ai.openai.embedding.options.model | The model to use | text-embedding-ada-002 (other options: text-embedding-3-large, text-embedding-3-small) | spring.ai.openai.embedding.options.encodingFormat | The format to return the embeddings in. Can be either float or base64. | - diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/openai-image.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/openai-image.adoc index 66766c960..7bf4dab10 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/openai-image.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/openai-image.adoc @@ -41,26 +41,6 @@ TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Man === Image Generation Properties - -The prefix `spring.ai.openai.image` is the property prefix that lets you configure the `ImageModel` implementation for OpenAI. - -[cols="3,5,1"] -|==== -| Property | Description | Default -| spring.ai.openai.image.enabled | Enable OpenAI image model. | true -| spring.ai.openai.image.base-url | Optional overrides the spring.ai.openai.base-url to provide chat specific url | - -| spring.ai.openai.image.api-key | Optional overrides the spring.ai.openai.api-key to provide chat specific api-key | - -| spring.ai.openai.image.options.n | The number of images to generate. Must be between 1 and 10. For dall-e-3, only n=1 is supported. | - -| spring.ai.openai.image.options.model | The model to use for image generation. | OpenAiImageApi.DEFAULT_IMAGE_MODEL -| spring.ai.openai.image.options.quality | The quality of the image that will be generated. HD creates images with finer details and greater consistency across the image. This parameter is only supported for dall-e-3. | - -| spring.ai.openai.image.options.response_format | The format in which the generated images are returned. Must be one of URL or b64_json. | - -| `spring.ai.openai.image.options.size` | The size of the generated images. Must be one of 256x256, 512x512, or 1024x1024 for dall-e-2. Must be one of 1024x1024, 1792x1024, or 1024x1792 for dall-e-3 models. | - -| `spring.ai.openai.image.options.size_width` | The width of the generated images. Must be one of 256, 512, or 1024 for dall-e-2. | - -| `spring.ai.openai.image.options.size_height`| The height of the generated images. Must be one of 256, 512, or 1024 for dall-e-2. | - -| `spring.ai.openai.image.options.style` | The style of the generated images. Must be one of vivid or natural. Vivid causes the model to lean towards generating hyper-real and dramatic images. Natural causes the model to produce more natural, less hyper-real looking images. This parameter is only supported for dall-e-3. | - -| `spring.ai.openai.image.options.user` | A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. | - -|==== - ==== Connection Properties The prefix `spring.ai.openai` is used as the property prefix that lets you connect to OpenAI. @@ -70,9 +50,12 @@ The prefix `spring.ai.openai` is used as the property prefix that lets you conne | Property | Description | Default | spring.ai.openai.base-url | The URL to connect to | https://api.openai.com | spring.ai.openai.api-key | The API Key | - +| spring.ai.openai.organization-id | Optionally you can specify which organization used for an API request. | - +| spring.ai.openai.project-id | Optionally, you can specify which project is used for an API request. | - |==== -==== Configuration Properties +TIP: For users that belong to multiple organizations (or are accessing their projects through their legacy user API key), optionally, you can specify which organization and project is used for an API request. +Usage from these API requests will count as usage for the specified organization and project. ==== Retry Properties @@ -92,6 +75,34 @@ The prefix `spring.ai.retry` is used as the property prefix that lets you config | spring.ai.retry.on-http-codes | List of HTTP status codes that should trigger a retry (e.g. to throw TransientAiException). | empty |==== +==== Configuration Properties + +The prefix `spring.ai.openai.image` is the property prefix that lets you configure the `ImageModel` implementation for OpenAI. + +[cols="3,5,1"] +|==== +| Property | Description | Default +| spring.ai.openai.image.enabled | Enable OpenAI image model. | true +| spring.ai.openai.image.base-url | Optional overrides the spring.ai.openai.base-url to provide chat specific url | - +| spring.ai.openai.image.api-key | Optional overrides the spring.ai.openai.api-key to provide chat specific api-key | - +| spring.ai.openai.image.organization-id | Optionally you can specify which organization used for an API request. | - +| spring.ai.openai.image.project-id | Optionally, you can specify which project is used for an API request. | - +| spring.ai.openai.image.options.n | The number of images to generate. Must be between 1 and 10. For dall-e-3, only n=1 is supported. | - +| spring.ai.openai.image.options.model | The model to use for image generation. | OpenAiImageApi.DEFAULT_IMAGE_MODEL +| spring.ai.openai.image.options.quality | The quality of the image that will be generated. HD creates images with finer details and greater consistency across the image. This parameter is only supported for dall-e-3. | - +| spring.ai.openai.image.options.response_format | The format in which the generated images are returned. Must be one of URL or b64_json. | - +| `spring.ai.openai.image.options.size` | The size of the generated images. Must be one of 256x256, 512x512, or 1024x1024 for dall-e-2. Must be one of 1024x1024, 1792x1024, or 1024x1792 for dall-e-3 models. | - +| `spring.ai.openai.image.options.size_width` | The width of the generated images. Must be one of 256, 512, or 1024 for dall-e-2. | - +| `spring.ai.openai.image.options.size_height`| The height of the generated images. Must be one of 256, 512, or 1024 for dall-e-2. | - +| `spring.ai.openai.image.options.style` | The style of the generated images. Must be one of vivid or natural. Vivid causes the model to lean towards generating hyper-real and dramatic images. Natural causes the model to produce more natural, less hyper-real looking images. This parameter is only supported for dall-e-3. | - +| `spring.ai.openai.image.options.user` | A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. | - +|==== + +NOTE: You can override the common `spring.ai.openai.base-url`, `spring.ai.openai.api-key`, `spring.ai.openai.organization-id` and `spring.ai.openai.project-id` properties. +The `spring.ai.openai.image.base-url`, `spring.ai.openai.image.api-key`, `spring.ai.openai.image.organization-id` and `spring.ai.openai.image.project-id` properties if set take precedence over the common properties. +This is useful if you want to use different OpenAI accounts for different models and different model endpoints. + +TIP: All properties prefixed with `spring.ai.openai.image.options` can be overridden at runtime. == Runtime Options [[image-options]] diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/speech/openai-speech.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/speech/openai-speech.adoc deleted file mode 100644 index 6ba1841ac..000000000 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/speech/openai-speech.adoc +++ /dev/null @@ -1,144 +0,0 @@ -= OpenAI Text-to-Speech (TTS) Integration - -== Introduction - -The Audio API provides a speech endpoint based on OpenAI's TTS (text-to-speech) model, enabling users to: - -- Narrate a written blog post. -- Produce spoken audio in multiple languages. -- Give real-time audio output using streaming. - -== Prerequisites - -. Create an OpenAI account and obtain an API key. You can sign up at the https://platform.openai.com/signup[OpenAI signup page] and generate an API key on the https://platform.openai.com/account/api-keys[API Keys page]. -. Add the `spring-ai-openai` dependency to your project's build file. For more information, refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section. - -== Auto-configuration - -Spring AI provides Spring Boot auto-configuration for the OpenAI Text-to-Speech Client. -To enable it add the following dependency to your project's Maven `pom.xml` file: - -[source,xml] ----- - - org.springframework.ai - spring-ai-openai-spring-boot-starter - ----- - -or to your Gradle `build.gradle` build file: - -[source,groovy] ----- -dependencies { - implementation 'org.springframework.ai:spring-ai-openai-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. - -=== TTS Properties - -The prefix `spring.ai.openai.audio.speech` is used as the property prefix that lets you configure the OpenAI Text-to-Speech client. - -[cols="3,5,2"] -|==== -| Property | Description | Default - -| spring.ai.openai.audio.speech.options.model | ID of the model to use. Only tts-1 is currently available. | tts-1 -| spring.ai.openai.audio.speech.options.voice | The voice to use for the TTS output. Available options are: alloy, echo, fable, onyx, nova, and shimmer. | alloy -| spring.ai.openai.audio.speech.options.response-format | The format of the audio output. Supported formats are mp3, opus, aac, flac, wav, and pcm. | mp3 -| spring.ai.openai.audio.speech.options.speed | The speed of the voice synthesis. The acceptable range is from 0.0 (slowest) to 1.0 (fastest). | 1.0 -|==== - -== Runtime Options [[speech-options]] - -The `OpenAiAudioSpeechOptions` class provides the options to use when making a text-to-speech request. -On start-up, the options specified by `spring.ai.openai.audio.speech` are used but you can override these at runtime. - -For example: - -[source,java] ----- -OpenAiAudioSpeechOptions speechOptions = OpenAiAudioSpeechOptions.builder() - .withModel("tts-1") - .withVoice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY) - .withResponseFormat(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3) - .withSpeed(1.0f) - .build(); - -SpeechPrompt speechPrompt = new SpeechPrompt("Hello, this is a text-to-speech example.", speechOptions); -SpeechResponse response = openAiAudioSpeechModel.call(speechPrompt); ----- - -== Manual Configuration - -Add the `spring-ai-openai` dependency to your project's Maven `pom.xml` file: - -[source,xml] ----- - - org.springframework.ai - spring-ai-openai - ----- - -or to your Gradle `build.gradle` build file: - -[source,groovy] ----- -dependencies { - implementation 'org.springframework.ai:spring-ai-openai' -} ----- - -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 an `OpenAiAudioSpeechModel`: - -[source,java] ----- -var openAiAudioApi = new OpenAiAudioApi(System.getenv("OPENAI_API_KEY")); - -var openAiAudioSpeechModel = new OpenAiAudioSpeechModel(openAiAudioApi); - -var speechOptions = OpenAiAudioSpeechOptions.builder() - .withResponseFormat(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3) - .withSpeed(1.0f) - .withModel(OpenAiAudioApi.TtsModel.TTS_1.value) - .build(); - -var speechPrompt = new SpeechPrompt("Hello, this is a text-to-speech example.", speechOptions); -SpeechResponse response = openAiAudioSpeechModel.call(speechPrompt); - -// Accessing metadata (rate limit info) -OpenAiAudioSpeechResponseMetadata metadata = response.getMetadata(); - -byte[] responseAsBytes = response.getResult().getOutput(); ----- - -== Streaming Real-time Audio - -The Speech API provides support for real-time audio streaming using chunk transfer encoding. This means that the audio is able to be played before the full file has been generated and made accessible. - -[source,java] ----- -var openAiAudioApi = new OpenAiAudioApi(System.getenv("OPENAI_API_KEY")); - -var openAiAudioSpeechModel = new OpenAiAudioSpeechModel(openAiAudioApi); - -OpenAiAudioSpeechOptions speechOptions = OpenAiAudioSpeechOptions.builder() - .withVoice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY) - .withSpeed(1.0f) - .withResponseFormat(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3) - .withModel(OpenAiAudioApi.TtsModel.TTS_1.value) - .build(); - -SpeechPrompt speechPrompt = new SpeechPrompt("Today is a wonderful day to build something people love!", speechOptions); - -Flux responseStream = openAiAudioSpeechModel.stream(speechPrompt); ----- - -== Example Code - -* The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/speech/OpenAiSpeechModelIT.java[OpenAiSpeechModelIT.java] test provides some general examples of how to use the library. diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/transcriptions/openai-transcriptions.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/transcriptions/openai-transcriptions.adoc deleted file mode 100644 index 5f352f4f9..000000000 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/transcriptions/openai-transcriptions.adoc +++ /dev/null @@ -1,118 +0,0 @@ -== OpenAI Transcriptions - -Spring AI supports https://platform.openai.com/docs/api-reference/audio/createTranscription[OpenAI's Transcription model]. - -== Prerequisites - - -You will need to create an API key with OpenAI to access ChatGPT models. -Create an account at https://platform.openai.com/signup[OpenAI signup page] and generate the token on the https://platform.openai.com/account/api-keys[API Keys page]. -The Spring AI project defines a configuration property named `spring.ai.openai.api-key` that you should set to the value of the `API Key` obtained from openai.com. -Exporting an environment variable is one way to set that configuration property: - - -== Auto-configuration - -Spring AI provides Spring Boot auto-configuration for the OpenAI Image Generation Client. -To enable it add the following dependency to your project's Maven `pom.xml` file: - -[source, xml] ----- - - org.springframework.ai - spring-ai-openai-spring-boot-starter - ----- - -or to your Gradle `build.gradle` build file. - -[source,groovy] ----- -dependencies { - implementation 'org.springframework.ai:spring-ai-openai-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. - -=== Transcription Properties - -The prefix `spring.ai.openai.audio.transcription` is used as the property prefix that lets you configure the retry mechanism for the OpenAI image model. - -[cols="3,5,2"] -|==== -| Property | Description | Default - -| spring.ai.openai.audio.transcription.options.model | ID of the model to use. Only whisper-1 (which is powered by our open source Whisper V2 model) is currently available. | whisper-1 -| spring.ai.openai.audio.transcription.options.response-format | The format of the transcript output, in one of these options: json, text, srt, verbose_json, or vtt. | json -| spring.ai.openai.audio.transcription.options.prompt | An optional text to guide the model's style or continue a previous audio segment. The prompt should match the audio language. | -| spring.ai.openai.audio.transcription.options.language | The language of the input audio. Supplying the input language in ISO-639-1 format will improve accuracy and latency. | -| spring.ai.openai.audio.transcription.options.temperature | The sampling temperature, 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. If set to 0, the model will use log probability to automatically increase the temperature until certain thresholds are hit. | 0 -| spring.ai.openai.audio.transcription.options.timestamp_granularities | The timestamp granularities to populate for this transcription. response_format must be set verbose_json to use timestamp granularities. Either or both of these options are supported: word, or segment. Note: There is no additional latency for segment timestamps, but generating word timestamps incurs additional latency. | segment -|==== - -== Runtime Options [[image-options]] - -The `OpenAiAudioTranscriptionOptions` class provides the options to use when making a transcription. -On start-up, the options specified by `spring.ai.openai.audio.transcription` are used but you can override these at runtime. - -For example: - -[source,java] ----- -OpenAiAudioApi.TranscriptResponseFormat responseFormat = OpenAiAudioApi.TranscriptResponseFormat.VTT; - -OpenAiAudioTranscriptionOptions transcriptionOptions = OpenAiAudioTranscriptionOptions.builder() - .withLanguage("en") - .withPrompt("Ask not this, but ask that") - .withTemperature(0f) - .withResponseFormat(responseFormat) - .build(); -AudioTranscriptionPrompt transcriptionRequest = new AudioTranscriptionPrompt(audioFile, transcriptionOptions); -AudioTranscriptionResponse response = openAiTranscriptionModel.call(transcriptionRequest); ----- - -== Manual Configuration - -Add the `spring-ai-openai` dependency to your project's Maven `pom.xml` file: - -[source, xml] ----- - - org.springframework.ai - spring-ai-openai - ----- - -or to your Gradle `build.gradle` build file. - -[source,groovy] ----- -dependencies { - implementation 'org.springframework.ai:spring-ai-openai' -} ----- - -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 `OpenAiAudioTranscriptionModel` - -[source,java] ----- -var openAiAudioApi = new OpenAiAudioApi(System.getenv("OPENAI_API_KEY")); - -var openAiAudioTranscriptionModel = new OpenAiAudioTranscriptionModel(openAiAudioApi); - -var transcriptionOptions = OpenAiAudioTranscriptionOptions.builder() - .withResponseFormat(TranscriptResponseFormat.TEXT) - .withTemperature(0f) - .build(); - -var audioFile = new FileSystemResource("/path/to/your/resource/speech/jfk.flac"); - -AudioTranscriptionPrompt transcriptionRequest = new AudioTranscriptionPrompt(audioFile, transcriptionOptions); -AudioTranscriptionResponse response = openAiTranscriptionModel.call(transcriptionRequest); ----- - -== Example Code -* The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/transcription/OpenAiTranscriptionModelIT.java[OpenAiTranscriptionModelIT.java] test provides some general examples how to use the library. \ No newline at end of file diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java index 27f8c49ca..6555faf0a 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java @@ -15,14 +15,22 @@ */ package org.springframework.ai.autoconfigure.openai; -import io.micrometer.observation.ObservationRegistry; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.jetbrains.annotations.NotNull; import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; import org.springframework.ai.chat.observation.ChatModelObservationConvention; import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; import org.springframework.ai.image.observation.ImageModelObservationConvention; import org.springframework.ai.model.function.FunctionCallback; import org.springframework.ai.model.function.FunctionCallbackContext; -import org.springframework.ai.openai.*; +import org.springframework.ai.openai.OpenAiAudioSpeechModel; +import org.springframework.ai.openai.OpenAiAudioTranscriptionModel; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiEmbeddingModel; +import org.springframework.ai.openai.OpenAiImageModel; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.openai.api.OpenAiAudioApi; import org.springframework.ai.openai.api.OpenAiImageApi; @@ -37,15 +45,16 @@ import org.springframework.boot.autoconfigure.web.reactive.function.client.WebCl import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; -import org.springframework.lang.NonNull; import org.springframework.retry.support.RetryTemplate; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; import org.springframework.web.client.ResponseErrorHandler; import org.springframework.web.client.RestClient; import org.springframework.web.reactive.function.client.WebClient; -import java.util.List; +import io.micrometer.observation.ObservationRegistry; /** * @author Christian Tzolov @@ -109,45 +118,27 @@ public class OpenAiAutoConfiguration { private OpenAiApi openAiApi(OpenAiChatProperties chatProperties, OpenAiConnectionProperties commonProperties, RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler, String modelType) { - ResolvedBaseUrlAndApiKey result = getResolvedBaseUrlAndApiKey(chatProperties.getBaseUrl(), - chatProperties.getApiKey(), commonProperties, modelType); - return new OpenAiApi(result.resolvedBaseUrl(), result.resolvedApiKey(), chatProperties.getCompletionsPath(), - OpenAiEmbeddingProperties.DEFAULT_EMBEDDINGS_PATH, restClientBuilder, webClientBuilder, - responseErrorHandler); + ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, chatProperties, + modelType); + + return new OpenAiApi(resolved.baseUrl(), resolved.apiKey(), resolved.headers(), + chatProperties.getCompletionsPath(), OpenAiEmbeddingProperties.DEFAULT_EMBEDDINGS_PATH, + restClientBuilder, webClientBuilder, responseErrorHandler); } private OpenAiApi openAiApi(OpenAiEmbeddingProperties embeddingProperties, OpenAiConnectionProperties commonProperties, RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler, String modelType) { - ResolvedBaseUrlAndApiKey result = getResolvedBaseUrlAndApiKey(embeddingProperties.getBaseUrl(), - embeddingProperties.getApiKey(), commonProperties, modelType); - return new OpenAiApi(result.resolvedBaseUrl(), result.resolvedApiKey(), + ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, embeddingProperties, + modelType); + + return new OpenAiApi(resolved.baseUrl(), resolved.apiKey(), resolved.headers(), OpenAiChatProperties.DEFAULT_COMPLETIONS_PATH, embeddingProperties.getEmbeddingsPath(), restClientBuilder, webClientBuilder, responseErrorHandler); } - private static @NonNull ResolvedBaseUrlAndApiKey getResolvedBaseUrlAndApiKey(String baseUrl, String apiKey, - OpenAiConnectionProperties commonProperties, String modelType) { - var commonBaseUrl = commonProperties.getBaseUrl(); - var commonApiKey = commonProperties.getApiKey(); - - String resolvedBaseUrl = StringUtils.hasText(baseUrl) ? baseUrl : commonBaseUrl; - Assert.hasText(resolvedBaseUrl, - "OpenAI base URL must be set. Use the connection property: spring.ai.openai.base-url or spring.ai.openai." - + modelType + ".base-url property."); - - String resolvedApiKey = StringUtils.hasText(apiKey) ? apiKey : commonApiKey; - Assert.hasText(resolvedApiKey, - "OpenAI API key must be set. Use the connection property: spring.ai.openai.api-key or spring.ai.openai." - + modelType + ".api-key property."); - return new ResolvedBaseUrlAndApiKey(resolvedBaseUrl, resolvedApiKey); - } - - private record ResolvedBaseUrlAndApiKey(String resolvedBaseUrl, String resolvedApiKey) { - } - @Bean @ConditionalOnMissingBean @ConditionalOnProperty(prefix = OpenAiImageProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", @@ -157,18 +148,10 @@ public class OpenAiAutoConfiguration { ResponseErrorHandler responseErrorHandler, ObjectProvider observationRegistry, ObjectProvider observationConvention) { - String apiKey = StringUtils.hasText(imageProperties.getApiKey()) ? imageProperties.getApiKey() - : commonProperties.getApiKey(); + ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, imageProperties, "image"); - String baseUrl = StringUtils.hasText(imageProperties.getBaseUrl()) ? imageProperties.getBaseUrl() - : commonProperties.getBaseUrl(); - - Assert.hasText(apiKey, - "OpenAI API key must be set. Use the property: spring.ai.openai.api-key or spring.ai.openai.image.api-key property."); - Assert.hasText(baseUrl, - "OpenAI base URL must be set. Use the property: spring.ai.openai.base-url or spring.ai.openai.image.base-url property."); - - var openAiImageApi = new OpenAiImageApi(baseUrl, apiKey, restClientBuilder, responseErrorHandler); + var openAiImageApi = new OpenAiImageApi(resolved.baseUrl(), resolved.apiKey(), resolved.headers(), + restClientBuilder, responseErrorHandler); var imageModel = new OpenAiImageModel(openAiImageApi, imageProperties.getOptions(), retryTemplate, observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); @@ -187,19 +170,11 @@ public class OpenAiAutoConfiguration { RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler) { - String apiKey = StringUtils.hasText(transcriptionProperties.getApiKey()) ? transcriptionProperties.getApiKey() - : commonProperties.getApiKey(); + ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, transcriptionProperties, + "transcription"); - String baseUrl = StringUtils.hasText(transcriptionProperties.getBaseUrl()) - ? transcriptionProperties.getBaseUrl() : commonProperties.getBaseUrl(); - - Assert.hasText(apiKey, - "OpenAI API key must be set. Use the property: spring.ai.openai.api-key or spring.ai.openai.audio.transcription.api-key property."); - Assert.hasText(baseUrl, - "OpenAI base URL must be set. Use the property: spring.ai.openai.base-url or spring.ai.openai.audio.transcription.base-url property."); - - var openAiAudioApi = new OpenAiAudioApi(baseUrl, apiKey, restClientBuilder, webClientBuilder, - responseErrorHandler); + var openAiAudioApi = new OpenAiAudioApi(resolved.baseUrl(), resolved.apiKey(), resolved.headers(), + restClientBuilder, webClientBuilder, responseErrorHandler); return new OpenAiAudioTranscriptionModel(openAiAudioApi, transcriptionProperties.getOptions(), retryTemplate); @@ -214,19 +189,11 @@ public class OpenAiAutoConfiguration { RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler) { - String apiKey = StringUtils.hasText(speechProperties.getApiKey()) ? speechProperties.getApiKey() - : commonProperties.getApiKey(); + ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, speechProperties, + "speach"); - String baseUrl = StringUtils.hasText(speechProperties.getBaseUrl()) ? speechProperties.getBaseUrl() - : commonProperties.getBaseUrl(); - - Assert.hasText(apiKey, - "OpenAI API key must be set. Use the property: spring.ai.openai.api-key or spring.ai.openai.audio.speech.api-key property."); - Assert.hasText(baseUrl, - "OpenAI base URL must be set. Use the property: spring.ai.openai.base-url or spring.ai.openai.audio.speech.base-url property."); - - var openAiAudioApi = new OpenAiAudioApi(baseUrl, apiKey, restClientBuilder, webClientBuilder, - responseErrorHandler); + var openAiAudioApi = new OpenAiAudioApi(resolved.baseUrl(), resolved.apiKey(), resolved.headers(), + restClientBuilder, webClientBuilder, responseErrorHandler); return new OpenAiAudioSpeechModel(openAiAudioApi, speechProperties.getOptions()); } @@ -239,4 +206,37 @@ public class OpenAiAutoConfiguration { return manager; } + private static @NotNull ResolvedConnectionProperties resolveConnectionProperties( + OpenAiParentProperties commonProperties, OpenAiParentProperties modelProperties, String modelType) { + + String baseUrl = StringUtils.hasText(modelProperties.getBaseUrl()) ? modelProperties.getBaseUrl() + : commonProperties.getBaseUrl(); + String apiKey = StringUtils.hasText(modelProperties.getApiKey()) ? modelProperties.getApiKey() + : commonProperties.getApiKey(); + String projectId = StringUtils.hasText(modelProperties.getProjectId()) ? modelProperties.getProjectId() + : commonProperties.getProjectId(); + String organizationId = StringUtils.hasText(modelProperties.getOrganizationId()) + ? modelProperties.getOrganizationId() : commonProperties.getOrganizationId(); + + Map> connectionHeaders = new HashMap<>(); + if (StringUtils.hasText(projectId)) { + connectionHeaders.put("OpenAI-Project", List.of(projectId)); + } + if (StringUtils.hasText(organizationId)) { + connectionHeaders.put("OpenAI-Organization", List.of(organizationId)); + } + + Assert.hasText(baseUrl, + "OpenAI base URL must be set. Use the connection property: spring.ai.openai.base-url or spring.ai.openai." + + modelType + ".base-url property."); + Assert.hasText(apiKey, + "OpenAI API key must be set. Use the connection property: spring.ai.openai.api-key or spring.ai.openai." + + modelType + ".api-key property."); + + return new ResolvedConnectionProperties(baseUrl, apiKey, CollectionUtils.toMultiValueMap(connectionHeaders)); + } + + private record ResolvedConnectionProperties(String baseUrl, String apiKey, MultiValueMap headers) { + } + } diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiParentProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiParentProperties.java index e4ba3a470..79aa3d833 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiParentProperties.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiParentProperties.java @@ -27,6 +27,10 @@ class OpenAiParentProperties { private String baseUrl; + private String projectId; + + private String organizationId; + public String getApiKey() { return apiKey; } @@ -43,4 +47,20 @@ class OpenAiParentProperties { this.baseUrl = baseUrl; } + public String getProjectId() { + return this.projectId; + } + + public void setProjectId(String projectId) { + this.projectId = projectId; + } + + public String getOrganizationId() { + return this.organizationId; + } + + public void setOrganizationId(String organizationId) { + this.organizationId = organizationId; + } + }