Fix dependencies in spring-ai-test module.
* Changes dependency on spring-ai-openai to spring-ai-core as a required dependency. * Include dependency on Jakarta Servlet API. * Include dependency on Spring Web MVC. * Include dependnecy on OkHttp3 MockWebServer. * Add common AI test configuration using mock objects. Rebase OpenAI mock test configuration on the shared components inside spring-ai-test. * Renames OpenAiMockTestConfiguration to MockOpenAiTestConfiguration. * Refactor MockOpenAiTestConfiguration removing mock infrastructure beans and import MockAiTestConfiguration class. * Declare test dependency on spring-ai-test.= * Integrate complete AI metadata implementation for Microsoft Azure OpenAI. This new Spring @Configuration class enables AI developers to test against the AI provider's REST API by mocking Web service endpoints and returning canned AI responses. This allows the AI provider client (Java) library to be exercised in the same manner as the production application, or even Spring AI framework code without modification. Closes #122
This commit is contained in:
@@ -45,6 +45,13 @@
|
||||
</dependency>
|
||||
|
||||
<!-- test dependencies -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental.ai</groupId>
|
||||
<artifactId>spring-ai-test</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
@@ -16,27 +16,31 @@
|
||||
|
||||
package org.springframework.ai.azure.openai.client;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import com.azure.ai.openai.OpenAIClient;
|
||||
import com.azure.ai.openai.models.ChatChoice;
|
||||
import com.azure.ai.openai.models.ChatCompletions;
|
||||
import com.azure.ai.openai.models.ChatCompletionsOptions;
|
||||
import com.azure.ai.openai.models.ChatMessage;
|
||||
import com.azure.ai.openai.models.ChatRole;
|
||||
import com.azure.ai.openai.models.PromptFilterResult;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.azure.openai.metadata.AzureOpenAiGenerationMetadata;
|
||||
import org.springframework.ai.client.AiClient;
|
||||
import org.springframework.ai.client.AiResponse;
|
||||
import org.springframework.ai.client.Generation;
|
||||
import org.springframework.ai.metadata.ChoiceMetadata;
|
||||
import org.springframework.ai.metadata.PromptMetadata;
|
||||
import org.springframework.ai.metadata.PromptMetadata.PromptFilterMetadata;
|
||||
import org.springframework.ai.prompt.Prompt;
|
||||
import org.springframework.ai.prompt.messages.Message;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@link AiClient} implementation for {@literal Microsoft Azure AI} backed by
|
||||
* {@link OpenAIClient}.
|
||||
@@ -55,11 +59,11 @@ public class AzureOpenAiClient implements AiClient {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(getClass());
|
||||
|
||||
private final OpenAIClient msoftOpenAiClient;
|
||||
private final OpenAIClient openAIClient;
|
||||
|
||||
public AzureOpenAiClient(OpenAIClient microsoftOpenAiClient) {
|
||||
Assert.notNull(microsoftOpenAiClient, "com.azure.ai.openai.OpenAIClient must not be null");
|
||||
this.msoftOpenAiClient = microsoftOpenAiClient;
|
||||
this.openAIClient = microsoftOpenAiClient;
|
||||
}
|
||||
|
||||
public String getModel() {
|
||||
@@ -88,7 +92,7 @@ public class AzureOpenAiClient implements AiClient {
|
||||
options.setModel(this.getModel());
|
||||
logger.trace("Azure Chat Message: {}", azureChatMessage);
|
||||
|
||||
ChatCompletions chatCompletions = this.msoftOpenAiClient.getChatCompletions(this.getModel(), options);
|
||||
ChatCompletions chatCompletions = this.openAIClient.getChatCompletions(this.getModel(), options);
|
||||
logger.trace("Azure ChatCompletions: {}", chatCompletions);
|
||||
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
@@ -120,14 +124,15 @@ public class AzureOpenAiClient implements AiClient {
|
||||
options.setModel(this.getModel());
|
||||
logger.trace("Azure ChatCompletionsOptions: {}", options);
|
||||
|
||||
ChatCompletions chatCompletions = this.msoftOpenAiClient.getChatCompletions(this.getModel(), options);
|
||||
ChatCompletions chatCompletions = this.openAIClient.getChatCompletions(this.getModel(), options);
|
||||
logger.trace("Azure ChatCompletions: {}", chatCompletions);
|
||||
|
||||
List<Generation> generations = new ArrayList<>();
|
||||
|
||||
for (ChatChoice choice : chatCompletions.getChoices()) {
|
||||
ChatMessage choiceMessage = choice.getMessage();
|
||||
Generation generation = new Generation(choiceMessage.getContent());
|
||||
Generation generation = new Generation(choiceMessage.getContent())
|
||||
.withChoiceMetadata(generateChoiceMetadata(choice));
|
||||
generations.add(generation);
|
||||
}
|
||||
|
||||
@@ -135,14 +140,22 @@ public class AzureOpenAiClient implements AiClient {
|
||||
.withPromptMetadata(generatePromptMetadata(chatCompletions));
|
||||
}
|
||||
|
||||
private ChoiceMetadata generateChoiceMetadata(ChatChoice choice) {
|
||||
return ChoiceMetadata.from(String.valueOf(choice.getFinishReason()), choice.getContentFilterResults());
|
||||
}
|
||||
|
||||
private PromptMetadata generatePromptMetadata(ChatCompletions chatCompletions) {
|
||||
|
||||
return PromptMetadata.of(chatCompletions.getPromptFilterResults()
|
||||
.stream()
|
||||
List<PromptFilterResult> promptFilterResults = nullSafeList(chatCompletions.getPromptFilterResults());
|
||||
|
||||
return PromptMetadata.of(promptFilterResults.stream()
|
||||
.map(promptFilterResult -> PromptFilterMetadata.from(promptFilterResult.getPromptIndex(),
|
||||
promptFilterResult.getContentFilterResults()))
|
||||
.toList());
|
||||
}
|
||||
|
||||
private <T> List<T> nullSafeList(List<T> list) {
|
||||
return list != null ? list : Collections.emptyList();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ public class AzureOpenAiUsage implements Usage {
|
||||
private final CompletionsUsage usage;
|
||||
|
||||
public AzureOpenAiUsage(CompletionsUsage usage) {
|
||||
Assert.notNull(usage, "CompletionUsage must not be null");
|
||||
Assert.notNull(usage, "CompletionsUsage must not be null");
|
||||
this.usage = usage;
|
||||
}
|
||||
|
||||
@@ -53,17 +53,17 @@ public class AzureOpenAiUsage implements Usage {
|
||||
|
||||
@Override
|
||||
public Long getPromptTokens() {
|
||||
return Integer.valueOf(getUsage().getPromptTokens()).longValue();
|
||||
return (long) getUsage().getPromptTokens();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getGenerationTokens() {
|
||||
return Integer.valueOf(getUsage().getCompletionTokens()).longValue();
|
||||
return (long) getUsage().getCompletionTokens();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getTotalTokens() {
|
||||
return Integer.valueOf(getUsage().getTotalTokens()).longValue();
|
||||
return (long) getUsage().getTotalTokens();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2023 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.azure.openai;
|
||||
|
||||
import static org.springframework.ai.test.config.MockAiTestConfiguration.SPRING_AI_API_PATH;
|
||||
|
||||
import com.azure.ai.openai.OpenAIClient;
|
||||
import com.azure.ai.openai.OpenAIClientBuilder;
|
||||
|
||||
import org.springframework.ai.azure.openai.client.AzureOpenAiClient;
|
||||
import org.springframework.ai.test.config.MockAiTestConfiguration;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.mockwebserver.Dispatcher;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
|
||||
/**
|
||||
* {@link SpringBootConfiguration} for testing {@literal Azure OpenAI's} API using mock
|
||||
* objects.
|
||||
* <p>
|
||||
* This test configuration allows Spring AI framework developers to mock Azure OpenAI's
|
||||
* API with Spring {@link MockMvc} and a test provided Spring Web MVC
|
||||
* {@link org.springframework.web.bind.annotation.RestController}.
|
||||
* <p>
|
||||
* This test configuration makes use of the OkHttp3 {@link MockWebServer} and
|
||||
* {@link Dispatcher} to integrate with Spring {@link MockMvc}.
|
||||
*
|
||||
* @author John Blum
|
||||
* @see org.springframework.boot.SpringBootConfiguration
|
||||
* @see org.springframework.ai.test.config.MockAiTestConfiguration
|
||||
* @since 0.7.0
|
||||
*/
|
||||
@SpringBootConfiguration
|
||||
@Profile("spring-ai-azure-openai-mocks")
|
||||
@Import(MockAiTestConfiguration.class)
|
||||
@SuppressWarnings("unused")
|
||||
public class MockAzureOpenAiTestConfiguration {
|
||||
|
||||
@Bean
|
||||
OpenAIClient microsoftAzureOpenAiClient(MockWebServer webServer) {
|
||||
|
||||
HttpUrl baseUrl = webServer.url(SPRING_AI_API_PATH);
|
||||
|
||||
return new OpenAIClientBuilder().endpoint(baseUrl.toString()).buildClient();
|
||||
}
|
||||
|
||||
@Bean
|
||||
AzureOpenAiClient azureOpenAiClient(OpenAIClient microsoftAzureOpenAiClient) {
|
||||
return new AzureOpenAiClient(microsoftAzureOpenAiClient);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* Copyright 2023 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.azure.openai.client;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.ai.test.config.MockAiTestConfiguration.SPRING_AI_API_PATH;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import com.azure.ai.openai.models.ContentFilterResult;
|
||||
import com.azure.ai.openai.models.ContentFilterResults;
|
||||
import com.azure.ai.openai.models.ContentFilterSeverity;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.ai.azure.openai.MockAzureOpenAiTestConfiguration;
|
||||
import org.springframework.ai.client.AiResponse;
|
||||
import org.springframework.ai.client.Generation;
|
||||
import org.springframework.ai.metadata.ChoiceMetadata;
|
||||
import org.springframework.ai.metadata.GenerationMetadata;
|
||||
import org.springframework.ai.metadata.PromptMetadata;
|
||||
import org.springframework.ai.metadata.RateLimit;
|
||||
import org.springframework.ai.metadata.Usage;
|
||||
import org.springframework.ai.prompt.Prompt;
|
||||
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.context.annotation.Import;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.context.request.WebRequest;
|
||||
|
||||
/**
|
||||
* Unit Tests for {@link AzureOpenAiClient} asserting AI metadata.
|
||||
*
|
||||
* @author John Blum
|
||||
* @since 0.7.0
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("spring-ai-azure-openai-mocks")
|
||||
@ContextConfiguration(classes = AzureOpenAiClientMetadataTests.TestConfiguration.class)
|
||||
@SuppressWarnings("unused")
|
||||
class AzureOpenAiClientMetadataTests {
|
||||
|
||||
@Autowired
|
||||
private AzureOpenAiClient aiClient;
|
||||
|
||||
@Test
|
||||
void azureOpenAiMetadataCapturedDuringGeneration() {
|
||||
|
||||
Prompt prompt = new Prompt("Can I fly like a bird?");
|
||||
|
||||
AiResponse response = this.aiClient.generate(prompt);
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
|
||||
Generation generation = response.getGeneration();
|
||||
|
||||
assertThat(generation).isNotNull()
|
||||
.extracting(Generation::getText)
|
||||
.isEqualTo("No! You will actually land with a resounding thud. This is the way!");
|
||||
|
||||
assertPromptMetadata(response);
|
||||
assertGenerationMetadata(response);
|
||||
assertChoiceMetadata(generation);
|
||||
}
|
||||
|
||||
private void assertPromptMetadata(AiResponse response) {
|
||||
|
||||
PromptMetadata promptMetadata = response.getPromptMetadata();
|
||||
|
||||
assertThat(promptMetadata).isNotNull();
|
||||
|
||||
PromptMetadata.PromptFilterMetadata promptFilterMetadata = promptMetadata.findByPromptIndex(0).orElse(null);
|
||||
|
||||
assertThat(promptFilterMetadata).isNotNull();
|
||||
assertThat(promptFilterMetadata.getPromptIndex()).isZero();
|
||||
assertContentFilterResults(promptFilterMetadata.getContentFilterMetadata(), ContentFilterSeverity.HIGH);
|
||||
}
|
||||
|
||||
private void assertGenerationMetadata(AiResponse response) {
|
||||
|
||||
GenerationMetadata generationMetadata = response.getGenerationMetadata();
|
||||
|
||||
assertThat(generationMetadata).isNotNull();
|
||||
assertThat(generationMetadata.getRateLimit()).isEqualTo(RateLimit.NULL);
|
||||
|
||||
Usage usage = generationMetadata.getUsage();
|
||||
|
||||
assertThat(usage).isNotNull();
|
||||
assertThat(usage).isNotEqualTo(Usage.NULL);
|
||||
assertThat(usage.getPromptTokens()).isEqualTo(58);
|
||||
assertThat(usage.getGenerationTokens()).isEqualTo(68);
|
||||
assertThat(usage.getTotalTokens()).isEqualTo(126);
|
||||
}
|
||||
|
||||
private void assertChoiceMetadata(Generation generation) {
|
||||
|
||||
ChoiceMetadata choiceMetadata = generation.getChoiceMetadata();
|
||||
|
||||
assertThat(choiceMetadata).isNotNull();
|
||||
assertThat(choiceMetadata.getFinishReason()).isEqualTo("stop");
|
||||
assertContentFilterResults(choiceMetadata.getContentFilterMetadata());
|
||||
}
|
||||
|
||||
private void assertContentFilterResults(ContentFilterResults contentFilterResults) {
|
||||
assertContentFilterResults(contentFilterResults, ContentFilterSeverity.SAFE);
|
||||
}
|
||||
|
||||
private void assertContentFilterResults(ContentFilterResults contentFilterResults,
|
||||
ContentFilterSeverity selfHarmSeverity) {
|
||||
|
||||
assertThat(contentFilterResults).isNotNull();
|
||||
assertContentFilterResult(contentFilterResults.getHate());
|
||||
assertContentFilterResult(contentFilterResults.getSelfHarm(), selfHarmSeverity);
|
||||
assertContentFilterResult(contentFilterResults.getSexual());
|
||||
assertContentFilterResult(contentFilterResults.getViolence());
|
||||
}
|
||||
|
||||
private void assertContentFilterResult(ContentFilterResult contentFilterResult) {
|
||||
|
||||
assertThat(contentFilterResult).isNotNull();
|
||||
assertContentFilterResult(contentFilterResult, contentFilterResult.getSeverity());
|
||||
}
|
||||
|
||||
private void assertContentFilterResult(ContentFilterResult contentFilterResult,
|
||||
ContentFilterSeverity expectedSeverity) {
|
||||
|
||||
boolean filtered = !ContentFilterSeverity.SAFE.equals(expectedSeverity);
|
||||
|
||||
assertThat(contentFilterResult).isNotNull();
|
||||
assertThat(contentFilterResult.isFiltered()).isEqualTo(filtered);
|
||||
assertThat(contentFilterResult.getSeverity()).isEqualTo(expectedSeverity);
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@Profile("spring-ai-azure-openai-mocks")
|
||||
@Import(MockAzureOpenAiTestConfiguration.class)
|
||||
static class TestConfiguration {
|
||||
|
||||
@Bean
|
||||
MockMvc mockMvc() {
|
||||
return MockMvcBuilders.standaloneSetup(new SpringAzureOpenAiChatCompletionsController()).build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequestMapping(SPRING_AI_API_PATH)
|
||||
@SuppressWarnings("all")
|
||||
static class SpringAzureOpenAiChatCompletionsController {
|
||||
|
||||
@PostMapping("/openai/deployments/gpt-35-turbo/chat/completions")
|
||||
ResponseEntity<?> chatCompletions(WebRequest request) {
|
||||
|
||||
String json = getJson();
|
||||
|
||||
ResponseEntity<?> response = ResponseEntity.status(HttpStatusCode.valueOf(200))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.contentLength(json.getBytes(StandardCharsets.UTF_8).length)
|
||||
.body(getJson());
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private String getJson() {
|
||||
return """
|
||||
{
|
||||
"id": "chatcmpl-6v7mkQj980V1yBec6ETrKPRqFjNw9",
|
||||
"object": "chat.completion",
|
||||
"created": 1679072642,
|
||||
"model": "gpt-35-turbo",
|
||||
"choices":[{
|
||||
"index": 0,
|
||||
"content_filter_results" : {
|
||||
"error" : null,
|
||||
"hate" : {
|
||||
"filtered" : false,
|
||||
"severity" : "safe"
|
||||
},
|
||||
"self_harm" : {
|
||||
"filtered" : false,
|
||||
"severity" : "safe"
|
||||
},
|
||||
"sexual" : {
|
||||
"filtered" : false,
|
||||
"severity" : "safe"
|
||||
},
|
||||
"violence" : {
|
||||
"filtered" : false,
|
||||
"severity" : "safe"
|
||||
}
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
"message":{
|
||||
"role": "user",
|
||||
"content": "No! You will actually land with a resounding thud. This is the way!"
|
||||
}
|
||||
}],
|
||||
"usage":{
|
||||
"prompt_tokens":58,
|
||||
"completion_tokens":68,
|
||||
"total_tokens":126
|
||||
},
|
||||
"prompt_annotations" : [{
|
||||
"prompt_index" : 0,
|
||||
"content_filter_results" : {
|
||||
"error" : null,
|
||||
"hate" : {
|
||||
"filtered" : false,
|
||||
"severity" : "safe"
|
||||
},
|
||||
"self_harm" : {
|
||||
"filtered" : true,
|
||||
"severity" : "high"
|
||||
},
|
||||
"sexual" : {
|
||||
"filtered" : false,
|
||||
"severity" : "safe"
|
||||
},
|
||||
"violence" : {
|
||||
"filtered" : false,
|
||||
"severity" : "safe"
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -68,26 +68,9 @@
|
||||
|
||||
<!-- test dependencies -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>jakarta.servlet</groupId>
|
||||
<artifactId>jakarta.servlet-api</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-webmvc</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>mockwebserver</artifactId>
|
||||
<groupId>org.springframework.experimental.ai</groupId>
|
||||
<artifactId>spring-ai-test</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 2023 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;
|
||||
|
||||
import static org.springframework.ai.test.config.MockAiTestConfiguration.SPRING_AI_API_PATH;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.theokanning.openai.client.OpenAiApi;
|
||||
import com.theokanning.openai.service.OpenAiService;
|
||||
|
||||
import org.springframework.ai.openai.client.OpenAiClient;
|
||||
import org.springframework.ai.openai.metadata.support.OpenAiHttpResponseHeadersInterceptor;
|
||||
import org.springframework.ai.test.config.MockAiTestConfiguration;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.mockwebserver.Dispatcher;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import retrofit2.Retrofit;
|
||||
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory;
|
||||
import retrofit2.converter.jackson.JacksonConverterFactory;
|
||||
|
||||
/**
|
||||
* {@link SpringBootConfiguration} for testing {@literal OpenAI's} API using mock objects.
|
||||
* <p>
|
||||
* This test configuration allows Spring AI framework developers to mock OpenAI's API with
|
||||
* Spring {@link MockMvc} and a test provided Spring Web MVC
|
||||
* {@link org.springframework.web.bind.annotation.RestController}.
|
||||
* <p>
|
||||
* This test configuration makes use of the OkHttp3 {@link MockWebServer} and
|
||||
* {@link Dispatcher} to integrate with Spring {@link MockMvc}.
|
||||
*
|
||||
* @author John Blum
|
||||
* @see org.springframework.boot.SpringBootConfiguration
|
||||
* @see org.springframework.ai.test.config.MockAiTestConfiguration
|
||||
* @since 0.7.0
|
||||
*/
|
||||
@SpringBootConfiguration
|
||||
@Profile("spring-ai-openai-mocks")
|
||||
@Import(MockAiTestConfiguration.class)
|
||||
@SuppressWarnings("unused")
|
||||
public class MockOpenAiTestConfiguration {
|
||||
|
||||
@Bean
|
||||
OpenAiService theoOpenAiService(MockWebServer webServer) {
|
||||
|
||||
String apiKey = UUID.randomUUID().toString();
|
||||
Duration timeout = Duration.ofSeconds(60);
|
||||
|
||||
ObjectMapper objectMapper = OpenAiService.defaultObjectMapper();
|
||||
|
||||
OkHttpClient httpClient = new OkHttpClient.Builder(OpenAiService.defaultClient(apiKey, timeout))
|
||||
.addInterceptor(new OpenAiHttpResponseHeadersInterceptor())
|
||||
.build();
|
||||
|
||||
HttpUrl baseUrl = webServer.url(SPRING_AI_API_PATH.concat("/"));
|
||||
|
||||
Retrofit retrofit = new Retrofit.Builder().baseUrl(baseUrl)
|
||||
.addConverterFactory(JacksonConverterFactory.create(objectMapper))
|
||||
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
|
||||
.client(httpClient)
|
||||
.build();
|
||||
|
||||
OpenAiApi api = retrofit.create(OpenAiApi.class);
|
||||
|
||||
return new OpenAiService(api);
|
||||
}
|
||||
|
||||
@Bean
|
||||
OpenAiClient apiClient(OpenAiService openAiService) {
|
||||
return new OpenAiClient(openAiService);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
package org.springframework.ai.openai.client;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.ai.test.config.MockAiTestConfiguration.SPRING_AI_API_PATH;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
@@ -29,7 +30,7 @@ import org.springframework.ai.metadata.GenerationMetadata;
|
||||
import org.springframework.ai.metadata.PromptMetadata;
|
||||
import org.springframework.ai.metadata.RateLimit;
|
||||
import org.springframework.ai.metadata.Usage;
|
||||
import org.springframework.ai.openai.OpenAiMockTestConfiguration;
|
||||
import org.springframework.ai.openai.MockOpenAiTestConfiguration;
|
||||
import org.springframework.ai.openai.metadata.support.OpenAiApiResponseHeaders;
|
||||
import org.springframework.ai.prompt.Prompt;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -58,8 +59,8 @@ import org.springframework.web.context.request.WebRequest;
|
||||
* @since 0.7.0
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ContextConfiguration(classes = OpenAiClientWithGenerationMetadataTests.TestConfiguration.class)
|
||||
@ActiveProfiles("spring-ai-openai-mocks")
|
||||
@ContextConfiguration(classes = OpenAiClientWithGenerationMetadataTests.TestConfiguration.class)
|
||||
@SuppressWarnings("unused")
|
||||
class OpenAiClientWithGenerationMetadataTests {
|
||||
|
||||
@@ -119,7 +120,7 @@ class OpenAiClientWithGenerationMetadataTests {
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@Import(OpenAiMockTestConfiguration.class)
|
||||
@Import(MockOpenAiTestConfiguration.class)
|
||||
static class TestConfiguration {
|
||||
|
||||
@Bean
|
||||
@@ -130,7 +131,7 @@ class OpenAiClientWithGenerationMetadataTests {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/spring-ai/api")
|
||||
@RequestMapping(SPRING_AI_API_PATH)
|
||||
@SuppressWarnings("all")
|
||||
static class SpringOpenAiChatCompletionsController {
|
||||
|
||||
|
||||
@@ -24,16 +24,30 @@
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>jakarta.servlet</groupId>
|
||||
<artifactId>jakarta.servlet-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-webmvc</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental.ai</groupId>
|
||||
<artifactId>spring-ai-openai</artifactId>
|
||||
<version>${project.parent.version}</version>
|
||||
<optional>true</optional>
|
||||
<artifactId>spring-ai-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>mockwebserver</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
</project>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.ai.openai;
|
||||
package org.springframework.ai.test.config;
|
||||
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@@ -23,27 +23,18 @@ import java.io.UnsupportedEncodingException;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import java.util.Queue;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentLinkedDeque;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.theokanning.openai.client.OpenAiApi;
|
||||
import com.theokanning.openai.service.OpenAiService;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.openai.client.OpenAiClient;
|
||||
import org.springframework.ai.openai.metadata.support.OpenAiHttpResponseHeadersInterceptor;
|
||||
import org.springframework.beans.factory.DisposableBean;
|
||||
import org.springframework.beans.factory.FactoryBean;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
@@ -53,26 +44,24 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.mockwebserver.Dispatcher;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import okhttp3.mockwebserver.RecordedRequest;
|
||||
import okio.Buffer;
|
||||
import retrofit2.Retrofit;
|
||||
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory;
|
||||
import retrofit2.converter.jackson.JacksonConverterFactory;
|
||||
|
||||
/**
|
||||
* {@link SpringBootConfiguration} for {@literal OpenAI's} API using mock objects.
|
||||
* Spring {@link Configuration} for AI integration testing using mock objects.
|
||||
* <p>
|
||||
* This test configuration allows Spring AI framework developers to mock OpenAI's API with
|
||||
* Spring {@link MockMvc} and a test provided Spring Web MVC
|
||||
* This test configuration allows Spring AI framework developers to mock an AI provider's
|
||||
* APIs with Spring {@link MockMvc} and a test provided Spring Web MVC
|
||||
* {@link org.springframework.web.bind.annotation.RestController}.
|
||||
* <p>
|
||||
* This test configuration makes use of the OkHttp3 {@link MockWebServer} and
|
||||
* {@link Dispatcher} to integrate with Spring {@link MockMvc}.
|
||||
* {@link Dispatcher} to integrate with Spring {@link MockMvc}. This allows you to mock
|
||||
* the AI response (e.g. JSON) coming back from the AI provider API and let it pass
|
||||
* through the underlying AI client library and infrastructure components responsible for
|
||||
* accessing the provider's AI with its API all the way back to Spring AI.
|
||||
*
|
||||
* @author John Blum
|
||||
* @see okhttp3.mockwebserver.Dispatcher
|
||||
@@ -81,14 +70,13 @@ import retrofit2.converter.jackson.JacksonConverterFactory;
|
||||
* @see org.springframework.test.web.servlet.MockMvc
|
||||
* @since 0.7.0
|
||||
*/
|
||||
@SpringBootConfiguration
|
||||
@Profile("spring-ai-openai-mocks")
|
||||
@Configuration
|
||||
@SuppressWarnings("unused")
|
||||
public class OpenAiMockTestConfiguration {
|
||||
public class MockAiTestConfiguration {
|
||||
|
||||
private static final Charset FALLBACK_CHARSET = StandardCharsets.UTF_8;
|
||||
public static final Charset FALLBACK_CHARSET = StandardCharsets.UTF_8;
|
||||
|
||||
private static final String SPRING_AI_API_PATH = "/spring-ai/api";
|
||||
public static final String SPRING_AI_API_PATH = "/spring-ai/api";
|
||||
|
||||
@Bean
|
||||
MockWebServerFactoryBean mockWebServer(MockMvc mockMvc) {
|
||||
@@ -97,36 +85,12 @@ public class OpenAiMockTestConfiguration {
|
||||
return factoryBean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
OpenAiService theoOpenAiService(MockWebServer webServer) {
|
||||
|
||||
String apiKey = UUID.randomUUID().toString();
|
||||
Duration timeout = Duration.ofSeconds(60);
|
||||
|
||||
ObjectMapper objectMapper = OpenAiService.defaultObjectMapper();
|
||||
|
||||
OkHttpClient httpClient = new OkHttpClient.Builder(OpenAiService.defaultClient(apiKey, timeout))
|
||||
.addInterceptor(new OpenAiHttpResponseHeadersInterceptor())
|
||||
.build();
|
||||
|
||||
HttpUrl baseUrl = webServer.url(SPRING_AI_API_PATH.concat("/"));
|
||||
|
||||
Retrofit retrofit = new Retrofit.Builder().baseUrl(baseUrl)
|
||||
.addConverterFactory(JacksonConverterFactory.create(objectMapper))
|
||||
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
|
||||
.client(httpClient)
|
||||
.build();
|
||||
|
||||
OpenAiApi api = retrofit.create(OpenAiApi.class);
|
||||
|
||||
return new OpenAiService(api);
|
||||
}
|
||||
|
||||
@Bean
|
||||
OpenAiClient apiClient(OpenAiService openAiService) {
|
||||
return new OpenAiClient(openAiService);
|
||||
}
|
||||
|
||||
/**
|
||||
* OkHttp {@link Dispatcher} implementation integrated with Spring Web MVC.
|
||||
*
|
||||
* @see okhttp3.mockwebserver.Dispatcher
|
||||
* @see org.springframework.test.web.servlet.MockMvc
|
||||
*/
|
||||
static class MockMvcDispatcher extends Dispatcher {
|
||||
|
||||
private final MockMvc mockMvc;
|
||||
@@ -141,6 +105,7 @@ public class OpenAiMockTestConfiguration {
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("all")
|
||||
public MockResponse dispatch(RecordedRequest request) {
|
||||
|
||||
try {
|
||||
@@ -213,7 +178,7 @@ public class OpenAiMockTestConfiguration {
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring {@link FactoryBean} used to construct, configure and properly initialize the
|
||||
* Spring {@link FactoryBean} used to construct, configure and initialize the
|
||||
* {@link MockWebServer} inside the Spring container.
|
||||
* <p>
|
||||
* Unfortunately, {@link MockWebServerFactoryBean} cannot implement the Spring
|
||||
@@ -226,8 +191,8 @@ public class OpenAiMockTestConfiguration {
|
||||
* <li>The MockWebServer.started is a private state variable</li>
|
||||
* <li>The overridden before() function is protected</li>
|
||||
* <li>The class is final and cannot be extended</li>
|
||||
* <li>Calling MockWebServer.url(:String) needed to construct Retrofit client in the
|
||||
* theoOpenAiService bean necessarily starts the MockWebServer</li>
|
||||
* <li>Calling MockWebServer.url(:String) is needed to construct Retrofit client in
|
||||
* the theoOpenAiService bean necessarily starts the MockWebServer</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* TODO: Figure out a way to implement the Spring {@link SmartLifecycle} interface
|
||||
@@ -235,6 +200,7 @@ public class OpenAiMockTestConfiguration {
|
||||
* methods.
|
||||
*
|
||||
* @see org.springframework.beans.factory.FactoryBean
|
||||
* @see org.springframework.beans.factory.DisposableBean
|
||||
* @see org.springframework.beans.factory.InitializingBean
|
||||
* @see okhttp3.mockwebserver.MockWebServer
|
||||
*/
|
||||
Reference in New Issue
Block a user