Add flexible API key management for OpenAI
Introduces a new API key management system that allows users to customize how API keys are provided and managed in their Spring AI applications. This change improves security and flexibility by: - Adding core ApiKey interface and SimpleApiKey implementation - Introducing @OpenAiApiKey qualifier for bean disambiguation - Supporting custom API key providers for secure key management - Adding auto-configuration support for API key injection - Adding builder pattern for OpenAiApi configuration - Deprecating public constructors in favor of builder API (since 1.0.0.M6) The new system enables users to implement their own key management strategies while maintaining backward compatibility with property-based configuration.
This commit is contained in:
@@ -31,8 +31,10 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.ai.model.ApiKey;
|
||||
import org.springframework.ai.model.ChatModelDescription;
|
||||
import org.springframework.ai.model.ModelOptionsUtils;
|
||||
import org.springframework.ai.model.SimpleApiKey;
|
||||
import org.springframework.ai.openai.api.common.OpenAiApiConstants;
|
||||
import org.springframework.ai.retry.RetryUtils;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
@@ -61,6 +63,10 @@ import org.springframework.web.reactive.function.client.WebClient;
|
||||
*/
|
||||
public class OpenAiApi {
|
||||
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -80,7 +86,9 @@ public class OpenAiApi {
|
||||
/**
|
||||
* Create a new chat completion api with base URL set to https://api.openai.com
|
||||
* @param apiKey OpenAI apiKey.
|
||||
* @deprecated since 1.0.0.M6 - use {@link #builder()} instead
|
||||
*/
|
||||
@Deprecated(since = "1.0.0.M6")
|
||||
public OpenAiApi(String apiKey) {
|
||||
this(OpenAiApiConstants.DEFAULT_BASE_URL, apiKey);
|
||||
}
|
||||
@@ -89,7 +97,9 @@ public class OpenAiApi {
|
||||
* Create a new chat completion api.
|
||||
* @param baseUrl api base URL.
|
||||
* @param apiKey OpenAI apiKey.
|
||||
* @deprecated since 1.0.0.M6 - use {@link #builder()} instead
|
||||
*/
|
||||
@Deprecated(since = "1.0.0.M6")
|
||||
public OpenAiApi(String baseUrl, String apiKey) {
|
||||
this(baseUrl, apiKey, RestClient.builder(), WebClient.builder());
|
||||
}
|
||||
@@ -100,7 +110,9 @@ public class OpenAiApi {
|
||||
* @param apiKey OpenAI apiKey.
|
||||
* @param restClientBuilder RestClient builder.
|
||||
* @param webClientBuilder WebClient builder.
|
||||
* @deprecated since 1.0.0.M6 - use {@link #builder()} instead
|
||||
*/
|
||||
@Deprecated(since = "1.0.0.M6")
|
||||
public OpenAiApi(String baseUrl, String apiKey, RestClient.Builder restClientBuilder,
|
||||
WebClient.Builder webClientBuilder) {
|
||||
this(baseUrl, apiKey, restClientBuilder, webClientBuilder, RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER);
|
||||
@@ -113,7 +125,9 @@ public class OpenAiApi {
|
||||
* @param restClientBuilder RestClient builder.
|
||||
* @param webClientBuilder WebClient builder.
|
||||
* @param responseErrorHandler Response error handler.
|
||||
* @deprecated since 1.0.0.M6 - use {@link #builder()} instead
|
||||
*/
|
||||
@Deprecated(since = "1.0.0.M6")
|
||||
public OpenAiApi(String baseUrl, String apiKey, RestClient.Builder restClientBuilder,
|
||||
WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler) {
|
||||
this(baseUrl, apiKey, "/v1/chat/completions", "/v1/embeddings", restClientBuilder, webClientBuilder,
|
||||
@@ -129,7 +143,9 @@ public class OpenAiApi {
|
||||
* @param restClientBuilder RestClient builder.
|
||||
* @param webClientBuilder WebClient builder.
|
||||
* @param responseErrorHandler Response error handler.
|
||||
* @deprecated since 1.0.0.M6 - use {@link #builder()} instead
|
||||
*/
|
||||
@Deprecated(since = "1.0.0.M6")
|
||||
public OpenAiApi(String baseUrl, String apiKey, String completionsPath, String embeddingsPath,
|
||||
RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder,
|
||||
ResponseErrorHandler responseErrorHandler) {
|
||||
@@ -148,10 +164,32 @@ public class OpenAiApi {
|
||||
* @param restClientBuilder RestClient builder.
|
||||
* @param webClientBuilder WebClient builder.
|
||||
* @param responseErrorHandler Response error handler.
|
||||
* @deprecated since 1.0.0.M6 - use {@link #builder()} instead
|
||||
*/
|
||||
@Deprecated(since = "1.0.0.M6")
|
||||
public OpenAiApi(String baseUrl, String apiKey, MultiValueMap<String, String> headers, String completionsPath,
|
||||
String embeddingsPath, RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder,
|
||||
ResponseErrorHandler responseErrorHandler) {
|
||||
this(baseUrl, new SimpleApiKey(apiKey), headers, completionsPath, embeddingsPath, restClientBuilder,
|
||||
webClientBuilder, responseErrorHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chat completion api.
|
||||
* @param baseUrl api base URL.
|
||||
* @param apiKey OpenAI apiKey.
|
||||
* @param headers the http headers to use.
|
||||
* @param completionsPath the path to the chat completions endpoint.
|
||||
* @param embeddingsPath the path to the embeddings endpoint.
|
||||
* @param restClientBuilder RestClient builder.
|
||||
* @param webClientBuilder WebClient builder.
|
||||
* @param responseErrorHandler Response error handler.
|
||||
* @deprecated since 1.0.0.M6 - use {@link #builder()} instead
|
||||
*/
|
||||
@Deprecated(since = "1.0.0.M6")
|
||||
public OpenAiApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, String> 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");
|
||||
@@ -161,7 +199,7 @@ public class OpenAiApi {
|
||||
this.embeddingsPath = embeddingsPath;
|
||||
// @formatter:off
|
||||
Consumer<HttpHeaders> finalHeaders = h -> {
|
||||
h.setBearerAuth(apiKey);
|
||||
h.setBearerAuth(apiKey.getValue());
|
||||
h.setContentType(MediaType.APPLICATION_JSON);
|
||||
h.addAll(headers);
|
||||
};
|
||||
@@ -1507,4 +1545,78 @@ public class OpenAiApi {
|
||||
@JsonProperty("usage") Usage usage) { // @formatter:on
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
|
||||
private String baseUrl = OpenAiApiConstants.DEFAULT_BASE_URL;
|
||||
|
||||
private ApiKey apiKey;
|
||||
|
||||
private MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
|
||||
|
||||
private String completionsPath = "/v1/chat/completions";
|
||||
|
||||
private String embeddingsPath = "/v1/embeddings";
|
||||
|
||||
private RestClient.Builder restClientBuilder = RestClient.builder();
|
||||
|
||||
private WebClient.Builder webClientBuilder = WebClient.builder();
|
||||
|
||||
private ResponseErrorHandler responseErrorHandler = RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER;
|
||||
|
||||
public Builder baseUrl(String baseUrl) {
|
||||
Assert.hasText(baseUrl, "baseUrl cannot be null or empty");
|
||||
this.baseUrl = baseUrl;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder apiKey(ApiKey apiKey) {
|
||||
Assert.notNull(apiKey, "apiKey cannot be null");
|
||||
this.apiKey = apiKey;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder headers(MultiValueMap<String, String> headers) {
|
||||
Assert.notNull(headers, "headers cannot be null");
|
||||
this.headers = headers;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder completionsPath(String completionsPath) {
|
||||
Assert.hasText(completionsPath, "completionsPath cannot be null or empty");
|
||||
this.completionsPath = completionsPath;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder embeddingsPath(String embeddingsPath) {
|
||||
Assert.hasText(embeddingsPath, "embeddingsPath cannot be null or empty");
|
||||
this.embeddingsPath = embeddingsPath;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder restClientBuilder(RestClient.Builder restClientBuilder) {
|
||||
Assert.notNull(restClientBuilder, "restClientBuilder cannot be null");
|
||||
this.restClientBuilder = restClientBuilder;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder webClientBuilder(WebClient.Builder webClientBuilder) {
|
||||
Assert.notNull(webClientBuilder, "webClientBuilder cannot be null");
|
||||
this.webClientBuilder = webClientBuilder;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder responseErrorHandler(ResponseErrorHandler responseErrorHandler) {
|
||||
Assert.notNull(responseErrorHandler, "responseErrorHandler cannot be null");
|
||||
this.responseErrorHandler = responseErrorHandler;
|
||||
return this;
|
||||
}
|
||||
|
||||
public OpenAiApi build() {
|
||||
Assert.notNull(this.apiKey, "apiKey must be set");
|
||||
return new OpenAiApi(this.baseUrl, this.apiKey, this.headers, this.completionsPath, this.embeddingsPath,
|
||||
this.restClientBuilder, this.webClientBuilder, this.responseErrorHandler);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2023-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.ai.openai.api;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* Qualifier annotation for OpenAI API key beans. Used to distinguish OpenAI API keys from
|
||||
* other provider API keys.
|
||||
*
|
||||
* @author Mark Pollack
|
||||
*/
|
||||
@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE, ElementType.ANNOTATION_TYPE })
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@Qualifier
|
||||
public @interface OpenAiApiKey {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright 2023-2025 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.ai.openai.api;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.ai.model.ApiKey;
|
||||
import org.springframework.ai.model.SimpleApiKey;
|
||||
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 static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class OpenAiApiBuilderTests {
|
||||
|
||||
private static final ApiKey TEST_API_KEY = new SimpleApiKey("test-api-key");
|
||||
|
||||
private static final String TEST_BASE_URL = "https://test.openai.com";
|
||||
|
||||
private static final String TEST_COMPLETIONS_PATH = "/test/completions";
|
||||
|
||||
private static final String TEST_EMBEDDINGS_PATH = "/test/embeddings";
|
||||
|
||||
@Test
|
||||
void testMinimalBuilder() {
|
||||
OpenAiApi api = OpenAiApi.builder().apiKey(TEST_API_KEY).build();
|
||||
|
||||
assertThat(api).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFullBuilder() {
|
||||
MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
|
||||
headers.add("Custom-Header", "test-value");
|
||||
RestClient.Builder restClientBuilder = RestClient.builder();
|
||||
WebClient.Builder webClientBuilder = WebClient.builder();
|
||||
ResponseErrorHandler errorHandler = mock(ResponseErrorHandler.class);
|
||||
|
||||
OpenAiApi api = OpenAiApi.builder()
|
||||
.apiKey(TEST_API_KEY)
|
||||
.baseUrl(TEST_BASE_URL)
|
||||
.headers(headers)
|
||||
.completionsPath(TEST_COMPLETIONS_PATH)
|
||||
.embeddingsPath(TEST_EMBEDDINGS_PATH)
|
||||
.restClientBuilder(restClientBuilder)
|
||||
.webClientBuilder(webClientBuilder)
|
||||
.responseErrorHandler(errorHandler)
|
||||
.build();
|
||||
|
||||
assertThat(api).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDefaultValues() {
|
||||
OpenAiApi api = OpenAiApi.builder().apiKey(TEST_API_KEY).build();
|
||||
|
||||
assertThat(api).isNotNull();
|
||||
// We can't directly test the default values as they're private fields,
|
||||
// but we know the builder succeeded with defaults
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMissingApiKey() {
|
||||
assertThatThrownBy(() -> {
|
||||
OpenAiApi.builder().build();
|
||||
}).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("apiKey must be set");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInvalidBaseUrl() {
|
||||
assertThatThrownBy(() -> {
|
||||
OpenAiApi.builder().baseUrl("").build();
|
||||
}).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("baseUrl cannot be null or empty");
|
||||
|
||||
assertThatThrownBy(() -> {
|
||||
OpenAiApi.builder().baseUrl(null).build();
|
||||
}).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("baseUrl cannot be null or empty");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInvalidHeaders() {
|
||||
assertThatThrownBy(() -> {
|
||||
OpenAiApi.builder().headers(null).build();
|
||||
}).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("headers cannot be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInvalidCompletionsPath() {
|
||||
assertThatThrownBy(() -> {
|
||||
OpenAiApi.builder().completionsPath("").build();
|
||||
}).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("completionsPath cannot be null or empty");
|
||||
|
||||
assertThatThrownBy(() -> {
|
||||
OpenAiApi.builder().completionsPath(null).build();
|
||||
}).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("completionsPath cannot be null or empty");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInvalidEmbeddingsPath() {
|
||||
assertThatThrownBy(() -> {
|
||||
OpenAiApi.builder().embeddingsPath("").build();
|
||||
}).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("embeddingsPath cannot be null or empty");
|
||||
|
||||
assertThatThrownBy(() -> {
|
||||
OpenAiApi.builder().embeddingsPath(null).build();
|
||||
}).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("embeddingsPath cannot be null or empty");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInvalidRestClientBuilder() {
|
||||
assertThatThrownBy(() -> {
|
||||
OpenAiApi.builder().restClientBuilder(null).build();
|
||||
}).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("restClientBuilder cannot be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInvalidWebClientBuilder() {
|
||||
assertThatThrownBy(() -> {
|
||||
OpenAiApi.builder().webClientBuilder(null).build();
|
||||
}).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("webClientBuilder cannot be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInvalidResponseErrorHandler() {
|
||||
assertThatThrownBy(() -> {
|
||||
OpenAiApi.builder().responseErrorHandler(null).build();
|
||||
}).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("responseErrorHandler cannot be null");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2023-2025 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.ai.model;
|
||||
|
||||
/**
|
||||
* Some model providers API leverage short-lived api keys which must be renewed at regular
|
||||
* intervals using another credential. For example, a GCP service account can be exchanged
|
||||
* for an api key to call Vertex AI.
|
||||
*
|
||||
* Model clients use the ApiKey interface to get an api key before they make any request
|
||||
* to the model provider. Implementations of this interface can cache the api key and
|
||||
* perform a key refresh when it is required.
|
||||
*
|
||||
* @author Adib Saikali
|
||||
*/
|
||||
public interface ApiKey {
|
||||
|
||||
/**
|
||||
* Returns an api key to use for a making request. Users of this method should NOT
|
||||
* cache the returned api key, instead call this method whenever you need an api key.
|
||||
* Implementors of this method MUST ensure that the returned key is not expired.
|
||||
* @return the current value of the api key
|
||||
*/
|
||||
String getValue();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2023-2025 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.ai.model;
|
||||
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* A simple implementation of {@link ApiKey} that holds an immutable API key value. This
|
||||
* implementation is suitable for cases where the API key is static and does not need to
|
||||
* be refreshed or rotated.
|
||||
*
|
||||
* @author Adib Saikali
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public final class SimpleApiKey implements ApiKey {
|
||||
|
||||
private final String value;
|
||||
|
||||
/**
|
||||
* Create a new SimpleApiKey.
|
||||
* @param value the API key value, must not be null or empty
|
||||
* @throws IllegalArgumentException if value is null or empty
|
||||
*/
|
||||
public SimpleApiKey(String value) {
|
||||
Assert.hasText(value, "API key value must not be null or empty");
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getValue() {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SimpleApiKey{value='***'}";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (!(o instanceof SimpleApiKey that)) {
|
||||
return false;
|
||||
}
|
||||
return this.value.equals(that.value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return this.value.hashCode();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -550,3 +550,46 @@ Follow the https://github.com/spring-projects/spring-ai/blob/main/models/spring-
|
||||
|
||||
* The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/tool/OpenAiApiToolFunctionCallIT.java[OpenAiApiToolFunctionCallIT.java] tests show how to use the low-level API to call tool functions.
|
||||
Based on the link:https://platform.openai.com/docs/guides/function-calling/parallel-function-calling[OpenAI Function Calling] tutorial.
|
||||
|
||||
== API Key Management
|
||||
|
||||
Spring AI provides flexible API key management through the `ApiKey` interface and its implementations. The default implementation, `SimpleApiKey`, is suitable for most use cases, but you can also create custom implementations for more complex scenarios.
|
||||
|
||||
=== Default Configuration
|
||||
|
||||
By default, Spring Boot auto-configuration will create an API key bean using the `spring.ai.openai.api-key` property:
|
||||
|
||||
[source,properties]
|
||||
----
|
||||
spring.ai.openai.api-key=your-api-key-here
|
||||
----
|
||||
|
||||
=== Custom API Key Configuration
|
||||
|
||||
You can provide your own `ApiKey` implementation by creating a bean with the `@OpenAiApiKey` qualifier:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@Configuration
|
||||
class OpenAiConfig {
|
||||
@Bean
|
||||
@OpenAiApiKey
|
||||
public ApiKey customOpenAiKey() {
|
||||
return new ApiKey() {
|
||||
@Override
|
||||
public String getValue() {
|
||||
// Custom logic to retrieve API key
|
||||
return "your-api-key-here";
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
This is useful when you need to:
|
||||
|
||||
* Retrieve the API key from a secure key store
|
||||
* Rotate API keys dynamically
|
||||
* Implement custom API key selection logic
|
||||
|
||||
NOTE: If you provide multiple `ApiKey` beans, make sure to use the `@OpenAiApiKey` qualifier to specify which one should be used for OpenAI operations. Without proper qualification, Spring will fail to start due to ambiguous bean definitions.
|
||||
|
||||
@@ -30,6 +30,9 @@ import org.springframework.ai.image.observation.ImageModelObservationConvention;
|
||||
import org.springframework.ai.model.function.DefaultFunctionCallbackResolver;
|
||||
import org.springframework.ai.model.function.FunctionCallback;
|
||||
import org.springframework.ai.model.function.FunctionCallbackResolver;
|
||||
import org.springframework.ai.model.ApiKey;
|
||||
import org.springframework.ai.model.SimpleApiKey;
|
||||
import org.springframework.ai.openai.api.OpenAiApiKey;
|
||||
import org.springframework.ai.openai.OpenAiAudioSpeechModel;
|
||||
import org.springframework.ai.openai.OpenAiAudioTranscriptionModel;
|
||||
import org.springframework.ai.openai.OpenAiChatModel;
|
||||
@@ -77,6 +80,13 @@ import org.springframework.web.reactive.function.client.WebClient;
|
||||
WebClientAutoConfiguration.class })
|
||||
public class OpenAiAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(ApiKey.class)
|
||||
@OpenAiApiKey
|
||||
public ApiKey openAiApiKey(OpenAiConnectionProperties properties) {
|
||||
return new SimpleApiKey(properties.getApiKey());
|
||||
}
|
||||
|
||||
private static @NotNull ResolvedConnectionProperties resolveConnectionProperties(
|
||||
OpenAiParentProperties commonProperties, OpenAiParentProperties modelProperties, String modelType) {
|
||||
|
||||
@@ -116,11 +126,11 @@ public class OpenAiAutoConfiguration {
|
||||
ObjectProvider<WebClient.Builder> webClientBuilderProvider, List<FunctionCallback> toolFunctionCallbacks,
|
||||
FunctionCallbackResolver functionCallbackResolver, RetryTemplate retryTemplate,
|
||||
ResponseErrorHandler responseErrorHandler, ObjectProvider<ObservationRegistry> observationRegistry,
|
||||
ObjectProvider<ChatModelObservationConvention> observationConvention) {
|
||||
ObjectProvider<ChatModelObservationConvention> observationConvention, @OpenAiApiKey ApiKey apiKey) {
|
||||
|
||||
var openAiApi = openAiApi(chatProperties, commonProperties,
|
||||
restClientBuilderProvider.getIfAvailable(RestClient::builder),
|
||||
webClientBuilderProvider.getIfAvailable(WebClient::builder), responseErrorHandler, "chat");
|
||||
webClientBuilderProvider.getIfAvailable(WebClient::builder), responseErrorHandler, "chat", apiKey);
|
||||
|
||||
var chatModel = new OpenAiChatModel(openAiApi, chatProperties.getOptions(), functionCallbackResolver,
|
||||
toolFunctionCallbacks, retryTemplate, observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP));
|
||||
@@ -138,11 +148,11 @@ public class OpenAiAutoConfiguration {
|
||||
OpenAiEmbeddingProperties embeddingProperties, ObjectProvider<RestClient.Builder> restClientBuilderProvider,
|
||||
ObjectProvider<WebClient.Builder> webClientBuilderProvider, RetryTemplate retryTemplate,
|
||||
ResponseErrorHandler responseErrorHandler, ObjectProvider<ObservationRegistry> observationRegistry,
|
||||
ObjectProvider<EmbeddingModelObservationConvention> observationConvention) {
|
||||
ObjectProvider<EmbeddingModelObservationConvention> observationConvention, @OpenAiApiKey ApiKey apiKey) {
|
||||
|
||||
var openAiApi = openAiApi(embeddingProperties, commonProperties,
|
||||
restClientBuilderProvider.getIfAvailable(RestClient::builder),
|
||||
webClientBuilderProvider.getIfAvailable(WebClient::builder), responseErrorHandler, "embedding");
|
||||
webClientBuilderProvider.getIfAvailable(WebClient::builder), responseErrorHandler, "embedding", apiKey);
|
||||
|
||||
var embeddingModel = new OpenAiEmbeddingModel(openAiApi, embeddingProperties.getMetadataMode(),
|
||||
embeddingProperties.getOptions(), retryTemplate,
|
||||
@@ -155,26 +165,41 @@ public class OpenAiAutoConfiguration {
|
||||
|
||||
private OpenAiApi openAiApi(OpenAiChatProperties chatProperties, OpenAiConnectionProperties commonProperties,
|
||||
RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder,
|
||||
ResponseErrorHandler responseErrorHandler, String modelType) {
|
||||
ResponseErrorHandler responseErrorHandler, String modelType, ApiKey apiKey) {
|
||||
|
||||
ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, chatProperties,
|
||||
modelType);
|
||||
|
||||
return new OpenAiApi(resolved.baseUrl(), resolved.apiKey(), resolved.headers(),
|
||||
chatProperties.getCompletionsPath(), OpenAiEmbeddingProperties.DEFAULT_EMBEDDINGS_PATH,
|
||||
restClientBuilder, webClientBuilder, responseErrorHandler);
|
||||
return OpenAiApi.builder()
|
||||
.baseUrl(resolved.baseUrl())
|
||||
.apiKey(apiKey)
|
||||
.headers(resolved.headers())
|
||||
.completionsPath(chatProperties.getCompletionsPath())
|
||||
.embeddingsPath(OpenAiEmbeddingProperties.DEFAULT_EMBEDDINGS_PATH)
|
||||
.restClientBuilder(restClientBuilder)
|
||||
.webClientBuilder(webClientBuilder)
|
||||
.responseErrorHandler(responseErrorHandler)
|
||||
.build();
|
||||
}
|
||||
|
||||
private OpenAiApi openAiApi(OpenAiEmbeddingProperties embeddingProperties,
|
||||
OpenAiConnectionProperties commonProperties, RestClient.Builder restClientBuilder,
|
||||
WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler, String modelType) {
|
||||
WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler, String modelType,
|
||||
ApiKey apiKey) {
|
||||
|
||||
ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, embeddingProperties,
|
||||
modelType);
|
||||
|
||||
return new OpenAiApi(resolved.baseUrl(), resolved.apiKey(), resolved.headers(),
|
||||
OpenAiChatProperties.DEFAULT_COMPLETIONS_PATH, embeddingProperties.getEmbeddingsPath(),
|
||||
restClientBuilder, webClientBuilder, responseErrorHandler);
|
||||
return OpenAiApi.builder()
|
||||
.baseUrl(resolved.baseUrl())
|
||||
.apiKey(apiKey)
|
||||
.headers(resolved.headers())
|
||||
.completionsPath(OpenAiChatProperties.DEFAULT_COMPLETIONS_PATH)
|
||||
.embeddingsPath(embeddingProperties.getEmbeddingsPath())
|
||||
.restClientBuilder(restClientBuilder)
|
||||
.webClientBuilder(webClientBuilder)
|
||||
.responseErrorHandler(responseErrorHandler)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright 2023-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.ai.autoconfigure.openai;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.ai.model.ApiKey;
|
||||
import org.springframework.ai.model.SimpleApiKey;
|
||||
import org.springframework.ai.openai.api.OpenAiApiKey;
|
||||
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for OpenAI ApiKey configuration behavior.
|
||||
*
|
||||
* @author Mark Pollack
|
||||
*/
|
||||
class OpenAiApiKeyConfigurationTests {
|
||||
|
||||
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.ai.openai.apiKey=test-key")
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class));
|
||||
|
||||
@Configuration
|
||||
static class CustomConfiguration {
|
||||
|
||||
@Bean
|
||||
@OpenAiApiKey
|
||||
public ApiKey customOpenAiKey() {
|
||||
return new ApiKey() {
|
||||
@Override
|
||||
public String getValue() {
|
||||
return "custom-key";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultApiKeyConfiguration() {
|
||||
this.contextRunner.run(context -> {
|
||||
ApiKey apiKey = context.getBean(ApiKey.class, ApiKey.class);
|
||||
assertThat(apiKey).isNotNull();
|
||||
assertThat(apiKey).isInstanceOf(SimpleApiKey.class);
|
||||
assertThat(apiKey.getValue()).isEqualTo("test-key");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void customApiKeyConfiguration() {
|
||||
this.contextRunner.withUserConfiguration(CustomConfiguration.class).run(context -> {
|
||||
ApiKey apiKey = context.getBean(ApiKey.class, ApiKey.class);
|
||||
assertThat(apiKey).isNotNull();
|
||||
assertThat(apiKey).isNotInstanceOf(SimpleApiKey.class);
|
||||
assertThat(apiKey.getValue()).isEqualTo("custom-key");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipleUnqualifiedApiKeysFailsToStart() {
|
||||
this.contextRunner.withUserConfiguration(MultipleUnqualifiedApiKeysConfiguration.class).run(context -> {
|
||||
assertThat(context).hasFailed();
|
||||
Throwable failure = context.getStartupFailure();
|
||||
while (failure.getCause() != null && !(failure instanceof NoSuchBeanDefinitionException)) {
|
||||
failure = failure.getCause();
|
||||
}
|
||||
assertThat(failure).isInstanceOf(NoSuchBeanDefinitionException.class)
|
||||
.hasMessageContaining(
|
||||
"No qualifying bean of type 'org.springframework.ai.model.ApiKey' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.ai.openai.api.OpenAiApiKey()}");
|
||||
});
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class MultipleUnqualifiedApiKeysConfiguration {
|
||||
|
||||
@Bean
|
||||
public ApiKey openAiKey() {
|
||||
return () -> "openai-key";
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ApiKey otherKey() {
|
||||
return () -> "other-key";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class MultipleQualifiedApiKeysConfiguration {
|
||||
|
||||
@Bean
|
||||
@OpenAiApiKey
|
||||
public ApiKey openAiKey() {
|
||||
return () -> "openai-key";
|
||||
}
|
||||
|
||||
@Bean
|
||||
@OtherApiKey
|
||||
public ApiKey otherKey() {
|
||||
return () -> "other-key";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2023-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.ai.autoconfigure.openai;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* Test qualifier annotation for other API key beans. Used to test multiple API key
|
||||
* configurations.
|
||||
*
|
||||
* @author Mark Pollack
|
||||
*/
|
||||
@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE, ElementType.ANNOTATION_TYPE })
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@Qualifier
|
||||
public @interface OtherApiKey {
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user