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 962b873aa61..751a63aee24 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 @@ -186,9 +186,7 @@ public OpenAiApi(String baseUrl, String apiKey, MultiValueMap he * @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 headers, String completionsPath, String embeddingsPath, RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler) { @@ -1680,7 +1678,7 @@ public Builder apiKey(ApiKey apiKey) { } public Builder apiKey(String simpleApiKey) { - Assert.notNull(simpleApiKey, "apiKey cannot be null"); + Assert.notNull(simpleApiKey, "simpleApiKey cannot be null"); this.apiKey = new SimpleApiKey(simpleApiKey); return this; } 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 b68f1f63762..33507bb9956 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 @@ -26,6 +26,9 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import org.springframework.ai.model.ApiKey; +import org.springframework.ai.model.NoopApiKey; +import org.springframework.ai.model.SimpleApiKey; import org.springframework.ai.openai.api.common.OpenAiApiConstants; import org.springframework.ai.retry.RetryUtils; import org.springframework.core.io.ByteArrayResource; @@ -45,6 +48,7 @@ * OpenAI Audio * * @author Christian Tzolov + * @author Ilayaperumal Gopinathan * @since 0.8.1 */ public class OpenAiAudioApi { @@ -56,7 +60,9 @@ public class OpenAiAudioApi { /** * Create a new audio api. * @param openAiToken OpenAI apiKey. + * @deprecated use {@link Builder} instead. */ + @Deprecated(forRemoval = true, since = "1.0.0-M6") public OpenAiAudioApi(String openAiToken) { this(OpenAiApiConstants.DEFAULT_BASE_URL, openAiToken, RestClient.builder(), WebClient.builder(), RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER); @@ -68,10 +74,11 @@ public OpenAiAudioApi(String openAiToken) { * @param openAiToken OpenAI apiKey. * @param restClientBuilder RestClient builder. * @param responseErrorHandler Response error handler. + * @deprecated use {@link Builder} instead. */ + @Deprecated(forRemoval = true, since = "1.0.0-M6") public OpenAiAudioApi(String baseUrl, String openAiToken, RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { - Consumer authHeaders; if (openAiToken != null && !openAiToken.isEmpty()) { authHeaders = h -> h.setBearerAuth(openAiToken); @@ -96,7 +103,9 @@ public OpenAiAudioApi(String baseUrl, String openAiToken, RestClient.Builder res * @param restClientBuilder RestClient builder. * @param webClientBuilder WebClient builder. * @param responseErrorHandler Response error handler. + * @deprecated use {@link Builder} instead. */ + @Deprecated(forRemoval = true, since = "1.0.0-M6") public OpenAiAudioApi(String baseUrl, String apiKey, RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler) { @@ -112,14 +121,31 @@ public OpenAiAudioApi(String baseUrl, String apiKey, RestClient.Builder restClie * @param restClientBuilder RestClient builder. * @param webClientBuilder WebClient builder. * @param responseErrorHandler Response error handler. + * @deprecated use {@link Builder} instead. */ + @Deprecated(forRemoval = true, since = "1.0.0-M6") public OpenAiAudioApi(String baseUrl, String apiKey, MultiValueMap headers, RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler) { + this(baseUrl, new SimpleApiKey(apiKey), headers, restClientBuilder, webClientBuilder, responseErrorHandler); + } + + /** + * Create a new audio 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, ApiKey apiKey, MultiValueMap headers, + RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, + ResponseErrorHandler responseErrorHandler) { Consumer authHeaders = h -> { - if (apiKey != null && !apiKey.isEmpty()) { - h.setBearerAuth(apiKey); + if (!(apiKey instanceof NoopApiKey)) { + h.setBearerAuth(apiKey.getValue()); } h.addAll(headers); // h.setContentType(MediaType.APPLICATION_JSON); @@ -133,6 +159,10 @@ public OpenAiAudioApi(String baseUrl, String apiKey, MultiValueMap headers = new LinkedMultiValueMap<>(); + + private RestClient.Builder restClientBuilder = RestClient.builder(); + + private WebClient.Builder webClientBuilder = WebClient.builder(); + + private ResponseErrorHandler responseErrorHandler = RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER; + + public Builder baseUrl(String baseUrl) { + Assert.hasText(baseUrl, "baseUrl cannot be null or empty"); + this.baseUrl = baseUrl; + return this; + } + + public Builder apiKey(ApiKey apiKey) { + Assert.notNull(apiKey, "apiKey cannot be null"); + this.apiKey = apiKey; + return this; + } + + public Builder apiKey(String simpleApiKey) { + Assert.notNull(simpleApiKey, "simpleApiKey cannot be null"); + this.apiKey = new SimpleApiKey(simpleApiKey); + return this; + } + + public Builder headers(MultiValueMap headers) { + Assert.notNull(headers, "headers cannot be null"); + this.headers = headers; + return this; + } + + public Builder 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 OpenAiAudioApi build() { + Assert.notNull(this.apiKey, "apiKey must be set"); + return new OpenAiAudioApi(this.baseUrl, this.apiKey, this.headers, this.restClientBuilder, + this.webClientBuilder, this.responseErrorHandler); + } + + } + } 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 06f071a1b52..56661f3e0b1 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 @@ -22,12 +22,16 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.ai.model.ApiKey; +import org.springframework.ai.model.NoopApiKey; +import org.springframework.ai.model.SimpleApiKey; import org.springframework.ai.openai.api.common.OpenAiApiConstants; import org.springframework.ai.retry.RetryUtils; 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; @@ -46,7 +50,9 @@ public class OpenAiImageApi { /** * Create a new OpenAI Image api with base URL set to {@code https://api.openai.com}. * @param openAiToken OpenAI apiKey. + * @deprecated use {@link Builder} instead. */ + @Deprecated(forRemoval = true, since = "1.0.0-M6") public OpenAiImageApi(String openAiToken) { this(OpenAiApiConstants.DEFAULT_BASE_URL, openAiToken, RestClient.builder()); } @@ -55,8 +61,9 @@ public OpenAiImageApi(String openAiToken) { * 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 restClientBuilder the rest client builder to use. + * @deprecated use {@link Builder} instead. */ + @Deprecated(forRemoval = true, since = "1.0.0-M6") public OpenAiImageApi(String baseUrl, String openAiToken, RestClient.Builder restClientBuilder) { this(baseUrl, openAiToken, restClientBuilder, RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER); } @@ -66,8 +73,9 @@ public OpenAiImageApi(String baseUrl, String openAiToken, RestClient.Builder res * @param baseUrl the base URL for the OpenAI API. * @param apiKey OpenAI apiKey. * @param restClientBuilder the rest client builder to use. - * @param responseErrorHandler the response error handler to use. + * @deprecated use {@link Builder} instead. */ + @Deprecated(forRemoval = true, since = "1.0.0-M6") public OpenAiImageApi(String baseUrl, String apiKey, RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { this(baseUrl, apiKey, CollectionUtils.toMultiValueMap(Map.of()), restClientBuilder, responseErrorHandler); @@ -80,15 +88,31 @@ public OpenAiImageApi(String baseUrl, String apiKey, RestClient.Builder restClie * @param headers the http headers to use. * @param restClientBuilder the rest client builder to use. * @param responseErrorHandler the response error handler to use. + * @deprecated use {@link Builder} instead. */ + @Deprecated(forRemoval = true, since = "1.0.0-M6") public OpenAiImageApi(String baseUrl, String apiKey, MultiValueMap headers, RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { + this(baseUrl, new SimpleApiKey(apiKey), headers, 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, ApiKey apiKey, MultiValueMap headers, + RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { + // @formatter:off this.restClient = restClientBuilder.baseUrl(baseUrl) .defaultHeaders(h -> { - if(apiKey != null && !apiKey.isEmpty()) { - h.setBearerAuth(apiKey); + if(!(apiKey instanceof NoopApiKey)) { + h.setBearerAuth(apiKey.getValue()); } h.setContentType(MediaType.APPLICATION_JSON); h.addAll(headers); @@ -169,4 +193,67 @@ public record Data(@JsonProperty("url") String url, @JsonProperty("b64_json") St } + public static Builder builder() { + return new Builder(); + } + + /** + * Builder to construct {@link OpenAiImageApi} instance. + */ + public static class Builder { + + private String baseUrl = OpenAiApiConstants.DEFAULT_BASE_URL; + + private ApiKey apiKey; + + private MultiValueMap headers = new LinkedMultiValueMap<>(); + + private RestClient.Builder restClientBuilder = RestClient.builder(); + + private ResponseErrorHandler responseErrorHandler = RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER; + + public Builder baseUrl(String baseUrl) { + Assert.hasText(baseUrl, "baseUrl cannot be null or empty"); + this.baseUrl = baseUrl; + return this; + } + + public Builder apiKey(ApiKey apiKey) { + Assert.notNull(apiKey, "apiKey cannot be null"); + this.apiKey = apiKey; + return this; + } + + public Builder apiKey(String simpleApiKey) { + Assert.notNull(simpleApiKey, "simpleApiKey cannot be null"); + this.apiKey = new SimpleApiKey(simpleApiKey); + return this; + } + + public Builder headers(MultiValueMap headers) { + Assert.notNull(headers, "headers cannot be null"); + this.headers = headers; + return this; + } + + public Builder restClientBuilder(RestClient.Builder restClientBuilder) { + Assert.notNull(restClientBuilder, "restClientBuilder cannot be null"); + this.restClientBuilder = restClientBuilder; + return this; + } + + public Builder responseErrorHandler(ResponseErrorHandler responseErrorHandler) { + Assert.notNull(responseErrorHandler, "responseErrorHandler cannot be null"); + this.responseErrorHandler = responseErrorHandler; + return this; + } + + public OpenAiImageApi build() { + Assert.notNull(this.apiKey, "apiKey must be set"); + return new OpenAiImageApi(this.baseUrl, this.apiKey, this.headers, this.restClientBuilder, + this.responseErrorHandler); + } + + } + } diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiModerationApi.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiModerationApi.java index 9496df68763..1a509fc6b31 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiModerationApi.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiModerationApi.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -20,10 +20,17 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.ai.model.ApiKey; +import org.springframework.ai.model.NoopApiKey; +import org.springframework.ai.model.SimpleApiKey; +import org.springframework.ai.openai.api.common.OpenAiApiConstants; import org.springframework.ai.retry.RetryUtils; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.client.ResponseErrorHandler; import org.springframework.web.client.RestClient; @@ -31,6 +38,7 @@ * OpenAI Moderation API. * * @author Ahmed Yousri + * @author Ilayaperumal Gopinathan * @see https://platform.openai.com/docs/api-reference/moderations */ @@ -47,11 +55,17 @@ public class OpenAiModerationApi { /** * Create a new OpenAI Moderation api with base URL set to https://api.openai.com * @param openAiToken OpenAI apiKey. + * @deprecated use {@link Builder} instead. */ + @Deprecated(forRemoval = true, since = "1.0.0-M6") public OpenAiModerationApi(String openAiToken) { this(DEFAULT_BASE_URL, openAiToken, RestClient.builder(), RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER); } + /** + * @deprecated use {@link Builder} instead. + */ + @Deprecated(forRemoval = true, since = "1.0.0-M6") public OpenAiModerationApi(String baseUrl, String openAiToken, RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { @@ -65,6 +79,26 @@ public OpenAiModerationApi(String baseUrl, String openAiToken, RestClient.Builde }).defaultStatusHandler(responseErrorHandler).build(); } + /** + * Create a new OpenAI Moderation API with the provided base URL. + * @param baseUrl the base URL for the OpenAI API. + * @param apiKey OpenAI apiKey. + * @param restClientBuilder the rest client builder to use. + */ + public OpenAiModerationApi(String baseUrl, ApiKey apiKey, MultiValueMap headers, + RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { + + this.objectMapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + this.restClient = restClientBuilder.baseUrl(baseUrl).defaultHeaders(h -> { + if (!(apiKey instanceof NoopApiKey)) { + h.setBearerAuth(apiKey.getValue()); + } + h.setContentType(MediaType.APPLICATION_JSON); + h.addAll(headers); + }).defaultStatusHandler(responseErrorHandler).build(); + } + public ResponseEntity createModeration(OpenAiModerationRequest openAiModerationRequest) { Assert.notNull(openAiModerationRequest, "Moderation request cannot be null."); Assert.hasLength(openAiModerationRequest.prompt(), "Prompt cannot be empty."); @@ -142,4 +176,67 @@ public record Data(@JsonProperty("url") String url, @JsonProperty("b64_json") St } + public static Builder builder() { + return new Builder(); + } + + /** + * Builder to construct {@link OpenAiModerationApi} instance. + */ + public static class Builder { + + private String baseUrl = OpenAiApiConstants.DEFAULT_BASE_URL; + + private ApiKey apiKey; + + private MultiValueMap headers = new LinkedMultiValueMap<>(); + + private RestClient.Builder restClientBuilder = RestClient.builder(); + + private ResponseErrorHandler responseErrorHandler = RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER; + + public Builder baseUrl(String baseUrl) { + Assert.hasText(baseUrl, "baseUrl cannot be null or empty"); + this.baseUrl = baseUrl; + return this; + } + + public Builder apiKey(ApiKey apiKey) { + Assert.notNull(apiKey, "apiKey cannot be null"); + this.apiKey = apiKey; + return this; + } + + public Builder apiKey(String simpleApiKey) { + Assert.notNull(simpleApiKey, "simpleApiKey cannot be null"); + this.apiKey = new SimpleApiKey(simpleApiKey); + return this; + } + + public Builder headers(MultiValueMap headers) { + Assert.notNull(headers, "headers cannot be null"); + this.headers = headers; + return this; + } + + public Builder restClientBuilder(RestClient.Builder restClientBuilder) { + Assert.notNull(restClientBuilder, "restClientBuilder cannot be null"); + this.restClientBuilder = restClientBuilder; + return this; + } + + public Builder responseErrorHandler(ResponseErrorHandler responseErrorHandler) { + Assert.notNull(responseErrorHandler, "responseErrorHandler cannot be null"); + this.responseErrorHandler = responseErrorHandler; + return this; + } + + public OpenAiModerationApi build() { + Assert.notNull(this.apiKey, "apiKey must be set"); + return new OpenAiModerationApi(this.baseUrl, this.apiKey, this.headers, this.restClientBuilder, + this.responseErrorHandler); + } + + } + } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java index 29ca366c462..d669d4ad2b6 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java @@ -16,6 +16,8 @@ package org.springframework.ai.openai; +import org.springframework.ai.model.ApiKey; +import org.springframework.ai.model.SimpleApiKey; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.openai.api.OpenAiApi.ChatModel; import org.springframework.ai.openai.api.OpenAiAudioApi; @@ -30,31 +32,31 @@ public class OpenAiTestConfiguration { @Bean public OpenAiApi openAiApi() { - return new OpenAiApi(getApiKey()); + return OpenAiApi.builder().apiKey(getApiKey()).build(); } @Bean public OpenAiImageApi openAiImageApi() { - return new OpenAiImageApi(getApiKey()); + return OpenAiImageApi.builder().apiKey(getApiKey()).build(); } @Bean public OpenAiAudioApi openAiAudioApi() { - return new OpenAiAudioApi(getApiKey()); + return OpenAiAudioApi.builder().apiKey(getApiKey()).build(); } @Bean public OpenAiModerationApi openAiModerationApi() { - return new OpenAiModerationApi(getApiKey()); + return OpenAiModerationApi.builder().apiKey(getApiKey()).build(); } - private String getApiKey() { + private ApiKey getApiKey() { String apiKey = System.getenv("OPENAI_API_KEY"); if (!StringUtils.hasText(apiKey)) { throw new IllegalArgumentException( "You must provide an API key. Put it in an environment variable under the name OPENAI_API_KEY"); } - return apiKey; + return new SimpleApiKey(apiKey); } @Bean diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/TranscriptionRequestTests.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/TranscriptionRequestTests.java index 172a7eada6f..b57ff322c28 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/TranscriptionRequestTests.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/TranscriptionRequestTests.java @@ -19,6 +19,7 @@ import org.junit.jupiter.api.Test; import org.springframework.ai.audio.transcription.AudioTranscriptionPrompt; +import org.springframework.ai.model.SimpleApiKey; import org.springframework.ai.openai.api.OpenAiAudioApi; import org.springframework.ai.openai.api.OpenAiAudioApi.TranscriptResponseFormat; import org.springframework.ai.openai.api.OpenAiAudioApi.TranscriptionRequest.GranularityType; @@ -35,7 +36,8 @@ public class TranscriptionRequestTests { @Test public void defaultOptions() { - var client = new OpenAiAudioTranscriptionModel(new OpenAiAudioApi("TEST"), + var client = new OpenAiAudioTranscriptionModel( + OpenAiAudioApi.builder().apiKey(new SimpleApiKey("TEST")).build(), OpenAiAudioTranscriptionOptions.builder() .model("DEFAULT_MODEL") .responseFormat(TranscriptResponseFormat.TEXT) @@ -59,7 +61,8 @@ public void defaultOptions() { @Test public void runtimeOptions() { - var client = new OpenAiAudioTranscriptionModel(new OpenAiAudioApi("TEST"), + var client = new OpenAiAudioTranscriptionModel( + OpenAiAudioApi.builder().apiKey(new SimpleApiKey("TEST")).build(), OpenAiAudioTranscriptionOptions.builder() .model("DEFAULT_MODEL") .responseFormat(TranscriptResponseFormat.TEXT) diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/api/OpenAiAudioApiIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/api/OpenAiAudioApiIT.java index 9f7d9681ae4..4b4fd9d24dd 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/api/OpenAiAudioApiIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/api/OpenAiAudioApiIT.java @@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.ai.model.SimpleApiKey; import org.springframework.ai.openai.api.OpenAiAudioApi; import org.springframework.ai.openai.api.OpenAiAudioApi.SpeechRequest; import org.springframework.ai.openai.api.OpenAiAudioApi.SpeechRequest.Voice; @@ -40,7 +41,9 @@ @EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") public class OpenAiAudioApiIT { - OpenAiAudioApi audioApi = new OpenAiAudioApi(System.getenv("OPENAI_API_KEY")); + OpenAiAudioApi audioApi = OpenAiAudioApi.builder() + .apiKey(new SimpleApiKey(System.getenv("OPENAI_API_KEY"))) + .build(); @SuppressWarnings("null") @Test diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/api/OpenAiAudioModelNoOpApiKeysIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/api/OpenAiAudioModelNoOpApiKeysIT.java new file mode 100644 index 00000000000..2f0e0988836 --- /dev/null +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/api/OpenAiAudioModelNoOpApiKeysIT.java @@ -0,0 +1,65 @@ +/* + * Copyright 2025-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.audio.api; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.model.NoopApiKey; +import org.springframework.ai.openai.api.OpenAiAudioApi; +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; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +/** + * @author Ilayaperumal Gopinathan + */ +@SpringBootTest(classes = OpenAiAudioModelNoOpApiKeysIT.Config.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +public class OpenAiAudioModelNoOpApiKeysIT { + + @Autowired + private OpenAiAudioApi audioApi; + + @Test + void checkNoOpKey() { + assertThatThrownBy(() -> { + this.audioApi + .createSpeech(OpenAiAudioApi.SpeechRequest.builder() + .model(OpenAiAudioApi.TtsModel.TTS_1_HD.getValue()) + .input("Hello, my name is Chris and I love Spring A.I.") + .voice(OpenAiAudioApi.SpeechRequest.Voice.ONYX) + .build()) + .getBody(); + }).isInstanceOf(NonTransientAiException.class); + } + + @SpringBootConfiguration + static class Config { + + @Bean + public OpenAiAudioApi openAiAudioApi() { + return OpenAiAudioApi.builder().apiKey(new NoopApiKey()).build(); + } + + } + +} diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/speech/OpenAiSpeechModelWithSpeechResponseMetadataTests.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/speech/OpenAiSpeechModelWithSpeechResponseMetadataTests.java index d65a8794c75..cbb46c3ede1 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/speech/OpenAiSpeechModelWithSpeechResponseMetadataTests.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/speech/OpenAiSpeechModelWithSpeechResponseMetadataTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -18,15 +18,16 @@ import java.time.Duration; +import org.hamcrest.core.StringContains; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.springframework.ai.model.SimpleApiKey; import org.springframework.ai.openai.OpenAiAudioSpeechModel; import org.springframework.ai.openai.OpenAiAudioSpeechOptions; import org.springframework.ai.openai.api.OpenAiAudioApi; import org.springframework.ai.openai.metadata.audio.OpenAiAudioSpeechResponseMetadata; import org.springframework.ai.openai.metadata.support.OpenAiApiResponseHeaders; -import org.springframework.ai.retry.RetryUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.test.autoconfigure.web.client.RestClientTest; @@ -38,6 +39,8 @@ import org.springframework.web.client.RestClient; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.matches; import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; @@ -111,7 +114,7 @@ private void prepareMock() { httpHeaders.set(OpenAiApiResponseHeaders.TOKENS_RESET_HEADER.getName(), "27h55s451ms"); httpHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM); - this.server.expect(requestTo("/v1/audio/speech")) + this.server.expect(requestTo(StringContains.containsString("/v1/audio/speech"))) .andExpect(method(HttpMethod.POST)) .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer " + TEST_API_KEY)) .andRespond(withSuccess("Audio bytes as string", MediaType.APPLICATION_OCTET_STREAM).headers(httpHeaders)); @@ -128,7 +131,7 @@ public OpenAiAudioSpeechModel openAiAudioSpeechClient(OpenAiAudioApi openAiAudio @Bean public OpenAiAudioApi openAiAudioApi(RestClient.Builder builder) { - return new OpenAiAudioApi("", TEST_API_KEY, builder, RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER); + return OpenAiAudioApi.builder().apiKey(new SimpleApiKey(TEST_API_KEY)).restClientBuilder(builder).build(); } } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/transcription/OpenAiTranscriptionModelWithTranscriptionResponseMetadataTests.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/transcription/OpenAiTranscriptionModelWithTranscriptionResponseMetadataTests.java index fa6af43a7ad..8b2dff36e4e 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/transcription/OpenAiTranscriptionModelWithTranscriptionResponseMetadataTests.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/transcription/OpenAiTranscriptionModelWithTranscriptionResponseMetadataTests.java @@ -18,6 +18,7 @@ import java.time.Duration; +import org.hamcrest.core.StringContains; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -25,11 +26,11 @@ import org.springframework.ai.audio.transcription.AudioTranscriptionPrompt; import org.springframework.ai.audio.transcription.AudioTranscriptionResponse; import org.springframework.ai.chat.metadata.RateLimit; +import org.springframework.ai.model.SimpleApiKey; import org.springframework.ai.openai.OpenAiAudioTranscriptionModel; import org.springframework.ai.openai.api.OpenAiAudioApi; import org.springframework.ai.openai.metadata.audio.OpenAiAudioTranscriptionResponseMetadata; import org.springframework.ai.openai.metadata.support.OpenAiApiResponseHeaders; -import org.springframework.ai.retry.RetryUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.test.autoconfigure.web.client.RestClientTest; @@ -120,7 +121,7 @@ private void prepareMock() { httpHeaders.set(OpenAiApiResponseHeaders.TOKENS_REMAINING_HEADER.getName(), "112358"); httpHeaders.set(OpenAiApiResponseHeaders.TOKENS_RESET_HEADER.getName(), "27h55s451ms"); - this.server.expect(requestTo("/v1/audio/transcriptions")) + this.server.expect(requestTo(StringContains.containsString("/v1/audio/transcriptions"))) .andExpect(method(HttpMethod.POST)) .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer " + TEST_API_KEY)) .andRespond(withSuccess(getJson(), MediaType.APPLICATION_JSON).headers(httpHeaders)); @@ -156,7 +157,7 @@ static class Config { @Bean public OpenAiAudioApi chatCompletionApi(RestClient.Builder builder) { - return new OpenAiAudioApi("", TEST_API_KEY, builder, RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER); + return OpenAiAudioApi.builder().apiKey(new SimpleApiKey(TEST_API_KEY)).restClientBuilder(builder).build(); } @Bean 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/OpenAiChatModelAdditionalHttpHeadersIT.java similarity index 90% rename from models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatModeAdditionalHttpHeadersIT.java rename to models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatModelAdditionalHttpHeadersIT.java index cb5b9936ca2..07af49fba89 100644 --- 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/OpenAiChatModelAdditionalHttpHeadersIT.java @@ -23,6 +23,7 @@ import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.SimpleApiKey; import org.springframework.ai.openai.OpenAiChatModel; import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.ai.openai.api.OpenAiApi; @@ -38,9 +39,9 @@ /** * @author Christian Tzolov */ -@SpringBootTest(classes = OpenAiChatModeAdditionalHttpHeadersIT.Config.class) +@SpringBootTest(classes = OpenAiChatModelAdditionalHttpHeadersIT.Config.class) @EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") -public class OpenAiChatModeAdditionalHttpHeadersIT { +public class OpenAiChatModelAdditionalHttpHeadersIT { @Autowired private OpenAiChatModel openAiChatModel; @@ -67,7 +68,7 @@ static class Config { @Bean public OpenAiApi chatCompletionApi() { - return new OpenAiApi("Invalid API Key"); + return OpenAiApi.builder().apiKey(new SimpleApiKey("Invalid API Key")).build(); } @Bean diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatModelNoOpApiKeysIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatModelNoOpApiKeysIT.java new file mode 100644 index 00000000000..9a73f110571 --- /dev/null +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatModelNoOpApiKeysIT.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025-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.chat; + +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.model.NoopApiKey; +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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +/** + * @author Ilayaperumal Gopinathan + */ +@SpringBootTest(classes = OpenAiChatModelNoOpApiKeysIT.Config.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +public class OpenAiChatModelNoOpApiKeysIT { + + @Autowired + private OpenAiChatModel openAiChatModel; + + @Test + void checkNoOpApiKey() { + assertThatThrownBy(() -> this.openAiChatModel.call("Tell me a joke")) + .isInstanceOf(NonTransientAiException.class); + } + + @SpringBootConfiguration + static class Config { + + @Bean + public OpenAiApi chatCompletionApi() { + return OpenAiApi.builder().apiKey(new NoopApiKey()).build(); + } + + @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/proxy/MistralWithOpenAiChatModelIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/proxy/MistralWithOpenAiChatModelIT.java index fcae688e25d..b2277f0ca3b 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/proxy/MistralWithOpenAiChatModelIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/proxy/MistralWithOpenAiChatModelIT.java @@ -391,7 +391,10 @@ static class Config { @Bean public OpenAiApi chatCompletionApi() { - return OpenAiApi.builder().baseUrl(MISTRAL_BASE_URL).apiKey(System.getenv("MISTRAL_AI_API_KEY")).build(); + return OpenAiApi.builder() + .baseUrl(MISTRAL_BASE_URL) + .apiKey(new SimpleApiKey(System.getenv("MISTRAL_AI_API_KEY"))) + .build(); } @Bean diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/proxy/OllamaWithOpenAiChatModelIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/proxy/OllamaWithOpenAiChatModelIT.java index 960aa8f460e..e169c700bff 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/proxy/OllamaWithOpenAiChatModelIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/proxy/OllamaWithOpenAiChatModelIT.java @@ -49,6 +49,8 @@ import org.springframework.ai.converter.ListOutputConverter; import org.springframework.ai.converter.MapOutputConverter; import org.springframework.ai.model.Media; +import org.springframework.ai.model.NoopApiKey; +import org.springframework.ai.model.SimpleApiKey; import org.springframework.ai.model.function.FunctionCallback; import org.springframework.ai.model.tool.LegacyToolCallingManager; import org.springframework.ai.openai.OpenAiChatModel; @@ -413,7 +415,7 @@ static class Config { @Bean public OpenAiApi chatCompletionApi() { - return OpenAiApi.builder().baseUrl(baseUrl).apiKey("").build(); + return OpenAiApi.builder().baseUrl(baseUrl).apiKey(new NoopApiKey()).build(); } @Bean diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/image/OpenAiImageModelNoOpApiKeysIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/image/OpenAiImageModelNoOpApiKeysIT.java new file mode 100644 index 00000000000..61160a08a1f --- /dev/null +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/image/OpenAiImageModelNoOpApiKeysIT.java @@ -0,0 +1,78 @@ +/* + * Copyright 2025-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.image; + +import io.micrometer.observation.tck.TestObservationRegistry; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.image.ImageOptionsBuilder; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.model.NoopApiKey; +import org.springframework.ai.openai.OpenAiImageModel; +import org.springframework.ai.openai.OpenAiImageOptions; +import org.springframework.ai.openai.api.OpenAiImageApi; +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; +import org.springframework.retry.support.RetryTemplate; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +/** + * @author Ilayaperumal Gopinathan + */ +@SpringBootTest(classes = OpenAiImageModelNoOpApiKeysIT.Config.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +public class OpenAiImageModelNoOpApiKeysIT { + + @Autowired + private OpenAiImageModel imageModel; + + @Test + void checkNoOpKey() { + assertThatThrownBy(() -> { + var options = ImageOptionsBuilder.builder().height(1024).width(1024).build(); + + var instructions = """ + A light cream colored mini golden doodle with a sign that contains the message "I'm on my way to BARCADE!"."""; + + ImagePrompt imagePrompt = new ImagePrompt(instructions, options); + + this.imageModel.call(imagePrompt); + }).isInstanceOf(NonTransientAiException.class); + } + + @SpringBootConfiguration + static class Config { + + @Bean + public OpenAiImageApi openAiImageApi() { + return OpenAiImageApi.builder().apiKey(new NoopApiKey()).build(); + } + + @Bean + public OpenAiImageModel openAiImageModel(OpenAiImageApi openAiImageApi) { + return new OpenAiImageModel(openAiImageApi, OpenAiImageOptions.builder().build(), + RetryTemplate.defaultInstance(), TestObservationRegistry.create()); + } + + } + +} diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/image/OpenAiImageModelObservationIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/image/OpenAiImageModelObservationIT.java index 40bf16f854c..37dc7abcdba 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/image/OpenAiImageModelObservationIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/image/OpenAiImageModelObservationIT.java @@ -24,6 +24,7 @@ import org.springframework.ai.image.ImagePrompt; import org.springframework.ai.image.ImageResponse; import org.springframework.ai.image.observation.DefaultImageModelObservationConvention; +import org.springframework.ai.model.SimpleApiKey; import org.springframework.ai.observation.conventions.AiOperationType; import org.springframework.ai.observation.conventions.AiProvider; import org.springframework.ai.openai.OpenAiImageModel; @@ -97,7 +98,7 @@ public TestObservationRegistry observationRegistry() { @Bean public OpenAiImageApi openAiImageApi() { - return new OpenAiImageApi(System.getenv("OPENAI_API_KEY")); + return OpenAiImageApi.builder().apiKey(new SimpleApiKey(System.getenv("OPENAI_API_KEY"))).build(); } @Bean diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/image/OpenAiImageModelWithImageResponseMetadataTests.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/image/OpenAiImageModelWithImageResponseMetadataTests.java index a331daef690..7847bf4e31b 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/image/OpenAiImageModelWithImageResponseMetadataTests.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/image/OpenAiImageModelWithImageResponseMetadataTests.java @@ -18,6 +18,7 @@ import java.util.List; +import org.hamcrest.core.StringContains; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -25,8 +26,10 @@ import org.springframework.ai.image.ImagePrompt; import org.springframework.ai.image.ImageResponse; import org.springframework.ai.image.ImageResponseMetadata; +import org.springframework.ai.model.SimpleApiKey; import org.springframework.ai.openai.OpenAiImageModel; import org.springframework.ai.openai.api.OpenAiImageApi; +import org.springframework.ai.openai.api.common.OpenAiApiConstants; import org.springframework.ai.openai.metadata.support.OpenAiApiResponseHeaders; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; @@ -104,7 +107,7 @@ private void prepareMock() { httpHeaders.set(OpenAiApiResponseHeaders.TOKENS_REMAINING_HEADER.getName(), "112358"); httpHeaders.set(OpenAiApiResponseHeaders.TOKENS_RESET_HEADER.getName(), "27h55s451ms"); - this.server.expect(requestTo("v1/images/generations")) + this.server.expect(requestTo(StringContains.containsString("v1/images/generations"))) .andExpect(method(HttpMethod.POST)) .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer " + TEST_API_KEY)) .andRespond(withSuccess(getJson(), MediaType.APPLICATION_JSON).headers(httpHeaders)); @@ -132,7 +135,7 @@ static class Config { @Bean public OpenAiImageApi imageGenerationApi(RestClient.Builder builder) { - return new OpenAiImageApi("", TEST_API_KEY, builder); + return OpenAiImageApi.builder().apiKey(new SimpleApiKey(TEST_API_KEY)).restClientBuilder(builder).build(); } @Bean diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/moderation/OpenAiModerationModelNoOpApiKeysIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/moderation/OpenAiModerationModelNoOpApiKeysIT.java new file mode 100644 index 00000000000..57bfcbb6046 --- /dev/null +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/moderation/OpenAiModerationModelNoOpApiKeysIT.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025-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.moderation; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.model.NoopApiKey; +import org.springframework.ai.moderation.ModerationPrompt; +import org.springframework.ai.openai.OpenAiModerationModel; +import org.springframework.ai.openai.api.OpenAiModerationApi; +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; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +/** + * @author Ilayaperumal Gopinathan + */ +@SpringBootTest(classes = OpenAiModerationModelNoOpApiKeysIT.Config.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +public class OpenAiModerationModelNoOpApiKeysIT { + + @Autowired + private OpenAiModerationModel moderationModel; + + @Test + void checkNoOpKey() { + assertThatThrownBy(() -> { + ModerationPrompt prompt = new ModerationPrompt("I want to kill them.."); + + this.moderationModel.call(prompt); + }).isInstanceOf(NonTransientAiException.class); + } + + @SpringBootConfiguration + static class Config { + + @Bean + public OpenAiModerationApi moderationGenerationApi() { + return OpenAiModerationApi.builder().apiKey(new NoopApiKey()).build(); + } + + @Bean + public OpenAiModerationModel openAiModerationClient(OpenAiModerationApi openAiModerationApi) { + return new OpenAiModerationModel(openAiModerationApi); + } + + } + +} diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/moderation/OpenAiModerationModelTests.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/moderation/OpenAiModerationModelTests.java index ca5e91ca984..600eb026087 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/moderation/OpenAiModerationModelTests.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/moderation/OpenAiModerationModelTests.java @@ -18,9 +18,12 @@ import java.util.List; +import org.hamcrest.core.StringContains; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.springframework.ai.model.ApiKey; +import org.springframework.ai.model.SimpleApiKey; import org.springframework.ai.moderation.Categories; import org.springframework.ai.moderation.CategoryScores; import org.springframework.ai.moderation.Generation; @@ -130,7 +133,7 @@ private void prepareMock() { httpHeaders.set(OpenAiApiResponseHeaders.TOKENS_REMAINING_HEADER.getName(), "112358"); httpHeaders.set(OpenAiApiResponseHeaders.TOKENS_RESET_HEADER.getName(), "27h55s451ms"); - this.server.expect(requestTo("v1/moderations")) + this.server.expect(requestTo(StringContains.containsString("v1/moderations"))) .andExpect(method(HttpMethod.POST)) .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer " + TEST_API_KEY)) .andRespond(withSuccess(getJson(), MediaType.APPLICATION_JSON).headers(httpHeaders)); @@ -182,7 +185,11 @@ static class Config { @Bean public OpenAiModerationApi moderationGenerationApi(RestClient.Builder builder) { - return new OpenAiModerationApi("", TEST_API_KEY, builder, RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER); + return OpenAiModerationApi.builder() + .apiKey(new SimpleApiKey(TEST_API_KEY)) + .restClientBuilder(builder) + .responseErrorHandler(RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER) + .build(); } @Bean 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 1f50ff40ce1..af934cd70cb 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 @@ -68,6 +68,7 @@ * @author Christian Tzolov * @author Stefan Vassilev * @author Thomas Vitale + * @author Ilayaperumal Gopinathan */ @AutoConfiguration(after = { RestClientAutoConfiguration.class, WebClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class, ToolCallingAutoConfiguration.class }) @@ -210,9 +211,13 @@ public OpenAiImageModel openAiImageModel(OpenAiConnectionProperties commonProper ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, imageProperties, "image"); - var openAiImageApi = new OpenAiImageApi(resolved.baseUrl(), resolved.apiKey(), resolved.headers(), - restClientBuilderProvider.getIfAvailable(RestClient::builder), responseErrorHandler); - + var openAiImageApi = OpenAiImageApi.builder() + .baseUrl(resolved.baseUrl()) + .apiKey(new SimpleApiKey(resolved.apiKey())) + .headers(resolved.headers()) + .restClientBuilder(restClientBuilderProvider.getIfAvailable(RestClient::builder)) + .responseErrorHandler(responseErrorHandler) + .build(); var imageModel = new OpenAiImageModel(openAiImageApi, imageProperties.getOptions(), retryTemplate, observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); @@ -233,9 +238,14 @@ public OpenAiAudioTranscriptionModel openAiAudioTranscriptionModel(OpenAiConnect ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, transcriptionProperties, "transcription"); - var openAiAudioApi = new OpenAiAudioApi(resolved.baseUrl(), resolved.apiKey(), resolved.headers(), - restClientBuilderProvider.getIfAvailable(RestClient::builder), - webClientBuilderProvider.getIfAvailable(WebClient::builder), responseErrorHandler); + var openAiAudioApi = OpenAiAudioApi.builder() + .baseUrl(resolved.baseUrl()) + .apiKey(new SimpleApiKey(resolved.apiKey())) + .headers(resolved.headers()) + .restClientBuilder(restClientBuilderProvider.getIfAvailable(RestClient::builder)) + .webClientBuilder(webClientBuilderProvider.getIfAvailable(WebClient::builder)) + .responseErrorHandler(responseErrorHandler) + .build(); return new OpenAiAudioTranscriptionModel(openAiAudioApi, transcriptionProperties.getOptions(), retryTemplate); @@ -250,9 +260,13 @@ public OpenAiModerationModel openAiModerationClient(OpenAiConnectionProperties c ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, moderationProperties, "moderation"); - var openAiModerationApi = new OpenAiModerationApi(resolved.baseUrl, resolved.apiKey(), - restClientBuilderProvider.getIfAvailable(RestClient::builder), responseErrorHandler); - + var openAiModerationApi = OpenAiModerationApi.builder() + .baseUrl(resolved.baseUrl) + .apiKey(new SimpleApiKey(resolved.apiKey())) + .headers(resolved.headers()) + .restClientBuilder(restClientBuilderProvider.getIfAvailable(RestClient::builder)) + .responseErrorHandler(responseErrorHandler) + .build(); return new OpenAiModerationModel(openAiModerationApi, retryTemplate) .withDefaultOptions(moderationProperties.getOptions()); } @@ -269,9 +283,14 @@ public OpenAiAudioSpeechModel openAiAudioSpeechClient(OpenAiConnectionProperties ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, speechProperties, "speach"); - var openAiAudioApi = new OpenAiAudioApi(resolved.baseUrl(), resolved.apiKey(), resolved.headers(), - restClientBuilderProvider.getIfAvailable(RestClient::builder), - webClientBuilderProvider.getIfAvailable(WebClient::builder), responseErrorHandler); + var openAiAudioApi = OpenAiAudioApi.builder() + .baseUrl(resolved.baseUrl()) + .apiKey(new SimpleApiKey(resolved.apiKey())) + .headers(resolved.headers()) + .restClientBuilder(restClientBuilderProvider.getIfAvailable(RestClient::builder)) + .webClientBuilder(webClientBuilderProvider.getIfAvailable(WebClient::builder)) + .responseErrorHandler(responseErrorHandler) + .build(); return new OpenAiAudioSpeechModel(openAiAudioApi, speechProperties.getOptions()); }