Initial observability for Spring AI

* Observation APIs for chat, embedding and image models
* Conventions based on OpenTelemetry Semantic Conventions for GenAI
* Instrumentation for OpenAI chat, embedding, and image models
* Autoconfiguration for observability for OpenAI

Fixes gh-953

Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
This commit is contained in:
Thomas Vitale
2024-08-01 22:25:43 +02:00
parent a077e1b952
commit 6bdb395686
77 changed files with 5801 additions and 85 deletions

View File

@@ -0,0 +1,102 @@
/*
* Copyright 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.chat.observation;
import io.micrometer.common.KeyValue;
import io.micrometer.observation.Observation;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.observation.AiOperationMetadata;
import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.HighCardinalityKeyNames;
/**
* Unit tests for {@link ChatModelCompletionObservationFilter}.
*
* @author Thomas Vitale
*/
class ChatModelCompletionObservationFilterTests {
private final ChatModelCompletionObservationFilter observationFilter = new ChatModelCompletionObservationFilter();
@Test
void whenNotSupportedObservationContextThenReturnOriginalContext() {
var expectedContext = new Observation.Context();
var actualContext = observationFilter.map(expectedContext);
assertThat(actualContext).isEqualTo(expectedContext);
}
@Test
void whenEmptyResponseThenReturnOriginalContext() {
var expectedContext = ChatModelObservationContext.builder()
.prompt(generatePrompt())
.operationMetadata(generateOperationMetadata())
.requestOptions(ChatModelRequestOptions.builder().model("mistral").build())
.build();
var actualContext = observationFilter.map(expectedContext);
assertThat(actualContext).isEqualTo(expectedContext);
}
@Test
void whenEmptyCompletionThenReturnOriginalContext() {
var expectedContext = ChatModelObservationContext.builder()
.prompt(generatePrompt())
.operationMetadata(generateOperationMetadata())
.requestOptions(ChatModelRequestOptions.builder().model("mistral").build())
.build();
expectedContext.setResponse(new ChatResponse(List.of(new Generation(new AssistantMessage("")))));
var actualContext = observationFilter.map(expectedContext);
assertThat(actualContext).isEqualTo(expectedContext);
}
@Test
void whenCompletionWithTextThenAugmentContext() {
var originalContext = ChatModelObservationContext.builder()
.prompt(generatePrompt())
.operationMetadata(generateOperationMetadata())
.requestOptions(ChatModelRequestOptions.builder().model("mistral").build())
.build();
originalContext.setResponse(new ChatResponse(List.of(new Generation(new AssistantMessage("say please")),
new Generation(new AssistantMessage("seriously, say please")))));
var augmentedContext = observationFilter.map(originalContext);
assertThat(augmentedContext.getHighCardinalityKeyValues()).contains(KeyValue
.of(HighCardinalityKeyNames.COMPLETION.asString(), "[\"say please\", \"seriously, say please\"]"));
}
private Prompt generatePrompt() {
return new Prompt("supercalifragilisticexpialidocious");
}
private AiOperationMetadata generateOperationMetadata() {
return AiOperationMetadata.builder()
.operationType(AiOperationType.CHAT.value())
.provider(AiProvider.OLLAMA.value())
.build();
}
}

View File

@@ -0,0 +1,121 @@
/*
* Copyright 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.chat.observation;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.observation.AiOperationMetadata;
import org.springframework.ai.observation.conventions.*;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.LowCardinalityKeyNames;
/**
* Unit tests for {@link ChatModelMeterObservationHandler}.
*
* @author Thomas Vitale
*/
class ChatModelMeterObservationHandlerTests {
private MeterRegistry meterRegistry;
private ObservationRegistry observationRegistry;
@BeforeEach
void setUp() {
this.meterRegistry = new SimpleMeterRegistry();
this.observationRegistry = ObservationRegistry.create();
this.observationRegistry.observationConfig()
.observationHandler(new ChatModelMeterObservationHandler(this.meterRegistry));
}
@Test
void shouldCreateAllMetersDuringAnObservation() {
var observationContext = generateObservationContext();
var observation = Observation
.createNotStarted(new DefaultChatModelObservationConvention(), () -> observationContext,
observationRegistry)
.start();
observationContext.setResponse(new ChatResponse(List.of(new Generation(new AssistantMessage("test"))),
ChatResponseMetadata.builder().withModel("mistral-42").withUsage(new TestUsage()).build()));
observation.stop();
assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()).meters()).hasSize(3);
assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value())
.tag(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), AiOperationType.CHAT.value())
.tag(LowCardinalityKeyNames.AI_PROVIDER.asString(), AiProvider.OLLAMA.value())
.tag(LowCardinalityKeyNames.REQUEST_MODEL.asString(), "mistral")
.tag(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), "mistral-42")
.meters()).hasSize(3);
assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value())
.tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.INPUT.value())
.meters()).hasSize(1);
assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value())
.tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.OUTPUT.value())
.meters()).hasSize(1);
assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value())
.tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.TOTAL.value())
.meters()).hasSize(1);
}
private ChatModelObservationContext generateObservationContext() {
return ChatModelObservationContext.builder()
.prompt(generatePrompt())
.operationMetadata(generateOperationMetadata())
.requestOptions(ChatModelRequestOptions.builder().model("mistral").build())
.build();
}
private Prompt generatePrompt() {
return new Prompt("hello");
}
private AiOperationMetadata generateOperationMetadata() {
return AiOperationMetadata.builder()
.operationType(AiOperationType.CHAT.value())
.provider(AiProvider.OLLAMA.value())
.build();
}
static class TestUsage implements Usage {
@Override
public Long getPromptTokens() {
return 1000L;
}
@Override
public Long getGenerationTokens() {
return 500L;
}
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright 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.chat.observation;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.observation.AiOperationMetadata;
import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link ChatModelObservationContext}.
*
* @author Thomas Vitale
*/
class ChatModelObservationContextTests {
@Test
void whenMandatoryRequestOptionsThenReturn() {
var observationContext = ChatModelObservationContext.builder()
.prompt(generatePrompt())
.operationMetadata(generateOperationMetadata())
.requestOptions(ChatModelRequestOptions.builder().model("supermodel").build())
.build();
assertThat(observationContext).isNotNull();
}
@Test
void whenRequestOptionsIsNullThenThrow() {
assertThatThrownBy(() -> ChatModelObservationContext.builder()
.prompt(generatePrompt())
.operationMetadata(generateOperationMetadata())
.requestOptions(null)
.build()).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("requestOptions cannot be null");
}
private Prompt generatePrompt() {
return new Prompt("hello");
}
private AiOperationMetadata generateOperationMetadata() {
return AiOperationMetadata.builder()
.operationType(AiOperationType.CHAT.value())
.provider(AiProvider.OLLAMA.value())
.build();
}
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright 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.chat.observation;
import io.micrometer.common.KeyValue;
import io.micrometer.observation.Observation;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.observation.AiOperationMetadata;
import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.HighCardinalityKeyNames;
/**
* Unit tests for {@link ChatModelPromptContentObservationFilter}.
*
* @author Thomas Vitale
*/
class ChatModelPromptContentObservationFilterTests {
private final ChatModelPromptContentObservationFilter observationFilter = new ChatModelPromptContentObservationFilter();
@Test
void whenNotSupportedObservationContextThenReturnOriginalContext() {
var expectedContext = new Observation.Context();
var actualContext = observationFilter.map(expectedContext);
assertThat(actualContext).isEqualTo(expectedContext);
}
@Test
void whenEmptyPromptThenReturnOriginalContext() {
var expectedContext = ChatModelObservationContext.builder()
.prompt(new Prompt(List.of()))
.operationMetadata(generateOperationMetadata())
.requestOptions(ChatModelRequestOptions.builder().model("mistral").build())
.build();
var actualContext = observationFilter.map(expectedContext);
assertThat(actualContext).isEqualTo(expectedContext);
}
@Test
void whenPromptWithTextThenAugmentContext() {
var originalContext = ChatModelObservationContext.builder()
.prompt(new Prompt("supercalifragilisticexpialidocious"))
.operationMetadata(generateOperationMetadata())
.requestOptions(ChatModelRequestOptions.builder().model("mistral").build())
.build();
var augmentedContext = observationFilter.map(originalContext);
assertThat(augmentedContext.getHighCardinalityKeyValues()).contains(
KeyValue.of(HighCardinalityKeyNames.PROMPT.asString(), "[\"supercalifragilisticexpialidocious\"]"));
}
@Test
void whenPromptWithMessagesThenAugmentContext() {
var originalContext = ChatModelObservationContext.builder()
.prompt(new Prompt(List.of(new SystemMessage("you're a chimney sweep"),
new UserMessage("supercalifragilisticexpialidocious"))))
.operationMetadata(generateOperationMetadata())
.requestOptions(ChatModelRequestOptions.builder().model("mistral").build())
.build();
var augmentedContext = observationFilter.map(originalContext);
assertThat(augmentedContext.getHighCardinalityKeyValues())
.contains(KeyValue.of(HighCardinalityKeyNames.PROMPT.asString(),
"[\"you're a chimney sweep\", \"supercalifragilisticexpialidocious\"]"));
}
private AiOperationMetadata generateOperationMetadata() {
return AiOperationMetadata.builder()
.operationType(AiOperationType.CHAT.value())
.provider(AiProvider.OLLAMA.value())
.build();
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 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.chat.observation;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link ChatModelRequestOptions}.
*
* @author Thomas Vitale
*/
class ChatModelRequestOptionsTests {
@Test
void whenMandatoryRequestOptionsThenReturn() {
var requestOptions = ChatModelRequestOptions.builder().model("rowena").build();
assertThat(requestOptions).isNotNull();
}
@Test
void whenModelIsNullThenThrow() {
assertThatThrownBy(() -> ChatModelRequestOptions.builder().build()).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("model cannot be null or empty");
}
@Test
void whenModelIsEmptyThenThrow() {
assertThatThrownBy(() -> ChatModelRequestOptions.builder().model("").build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("model cannot be null or empty");
}
}

View File

@@ -0,0 +1,176 @@
/*
* Copyright 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.chat.observation;
import io.micrometer.common.KeyValue;
import io.micrometer.observation.Observation;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.observation.AiOperationMetadata;
import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.HighCardinalityKeyNames;
import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.LowCardinalityKeyNames;
/**
* Unit tests for {@link DefaultChatModelObservationConvention}.
*
* @author Thomas Vitale
*/
class DefaultChatModelObservationConventionTests {
private final DefaultChatModelObservationConvention observationConvention = new DefaultChatModelObservationConvention();
@Test
void shouldHaveName() {
assertThat(this.observationConvention.getName()).isEqualTo(DefaultChatModelObservationConvention.DEFAULT_NAME);
}
@Test
void shouldHaveContextualName() {
ChatModelObservationContext observationContext = ChatModelObservationContext.builder()
.prompt(generatePrompt())
.operationMetadata(generateOperationMetadata())
.requestOptions(ChatModelRequestOptions.builder().model("mistral").build())
.build();
assertThat(this.observationConvention.getContextualName(observationContext)).isEqualTo("chat mistral");
}
@Test
void supportsOnlyChatModelObservationContext() {
ChatModelObservationContext observationContext = ChatModelObservationContext.builder()
.prompt(generatePrompt())
.operationMetadata(generateOperationMetadata())
.requestOptions(ChatModelRequestOptions.builder().model("mistral").build())
.build();
assertThat(this.observationConvention.supportsContext(observationContext)).isTrue();
assertThat(this.observationConvention.supportsContext(new Observation.Context())).isFalse();
}
@Test
void shouldHaveRequiredKeyValues() {
ChatModelObservationContext observationContext = ChatModelObservationContext.builder()
.prompt(generatePrompt())
.operationMetadata(generateOperationMetadata())
.requestOptions(ChatModelRequestOptions.builder().model("mistral").build())
.build();
assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext)).contains(
KeyValue.of(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), "chat"),
KeyValue.of(LowCardinalityKeyNames.AI_PROVIDER.asString(), "ollama"),
KeyValue.of(LowCardinalityKeyNames.REQUEST_MODEL.asString(), "mistral"));
}
@Test
void shouldHaveOptionalKeyValues() {
ChatModelObservationContext observationContext = ChatModelObservationContext.builder()
.prompt(generatePrompt())
.operationMetadata(generateOperationMetadata())
.requestOptions(ChatModelRequestOptions.builder()
.model("mistral")
.frequencyPenalty(0.8f)
.maxTokens(200)
.presencePenalty(1.0f)
.stopSequences(List.of("addio", "bye"))
.temperature(0.5f)
.topK(1)
.topP(0.9f)
.build())
.build();
observationContext.setResponse(new ChatResponse(
List.of(new Generation(new AssistantMessage("response"),
ChatGenerationMetadata.from("this-is-the-end", null))),
ChatResponseMetadata.builder()
.withId("say33")
.withModel("mistral-42")
.withUsage(new TestUsage())
.build()));
assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext))
.contains(KeyValue.of(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), "mistral-42"));
assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext)).contains(
KeyValue.of(HighCardinalityKeyNames.REQUEST_FREQUENCY_PENALTY.asString(), "0.8"),
KeyValue.of(HighCardinalityKeyNames.REQUEST_MAX_TOKENS.asString(), "200"),
KeyValue.of(HighCardinalityKeyNames.REQUEST_PRESENCE_PENALTY.asString(), "1.0"),
KeyValue.of(HighCardinalityKeyNames.REQUEST_STOP_SEQUENCES.asString(), "[\"addio\", \"bye\"]"),
KeyValue.of(HighCardinalityKeyNames.REQUEST_TEMPERATURE.asString(), "0.5"),
KeyValue.of(HighCardinalityKeyNames.REQUEST_TOP_K.asString(), "1"),
KeyValue.of(HighCardinalityKeyNames.REQUEST_TOP_P.asString(), "0.9"),
KeyValue.of(HighCardinalityKeyNames.RESPONSE_FINISH_REASON.asString(), "this-is-the-end"),
KeyValue.of(HighCardinalityKeyNames.RESPONSE_ID.asString(), "say33"),
KeyValue.of(HighCardinalityKeyNames.USAGE_INPUT_TOKENS.asString(), "1000"),
KeyValue.of(HighCardinalityKeyNames.USAGE_OUTPUT_TOKENS.asString(), "500"),
KeyValue.of(HighCardinalityKeyNames.USAGE_TOTAL_TOKENS.asString(), "1500"));
}
@Test
void shouldHaveMissingKeyValues() {
ChatModelObservationContext observationContext = ChatModelObservationContext.builder()
.prompt(generatePrompt())
.operationMetadata(generateOperationMetadata())
.requestOptions(ChatModelRequestOptions.builder().model("mistral").build())
.build();
assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext))
.contains(KeyValue.of(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), KeyValue.NONE_VALUE));
assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext)).contains(
KeyValue.of(HighCardinalityKeyNames.REQUEST_FREQUENCY_PENALTY.asString(), KeyValue.NONE_VALUE),
KeyValue.of(HighCardinalityKeyNames.REQUEST_MAX_TOKENS.asString(), KeyValue.NONE_VALUE),
KeyValue.of(HighCardinalityKeyNames.REQUEST_PRESENCE_PENALTY.asString(), KeyValue.NONE_VALUE),
KeyValue.of(HighCardinalityKeyNames.REQUEST_STOP_SEQUENCES.asString(), KeyValue.NONE_VALUE),
KeyValue.of(HighCardinalityKeyNames.REQUEST_TEMPERATURE.asString(), KeyValue.NONE_VALUE),
KeyValue.of(HighCardinalityKeyNames.REQUEST_TOP_K.asString(), KeyValue.NONE_VALUE),
KeyValue.of(HighCardinalityKeyNames.REQUEST_TOP_P.asString(), KeyValue.NONE_VALUE),
KeyValue.of(HighCardinalityKeyNames.RESPONSE_FINISH_REASON.asString(), KeyValue.NONE_VALUE),
KeyValue.of(HighCardinalityKeyNames.RESPONSE_ID.asString(), KeyValue.NONE_VALUE),
KeyValue.of(HighCardinalityKeyNames.USAGE_INPUT_TOKENS.asString(), KeyValue.NONE_VALUE),
KeyValue.of(HighCardinalityKeyNames.USAGE_OUTPUT_TOKENS.asString(), KeyValue.NONE_VALUE),
KeyValue.of(HighCardinalityKeyNames.USAGE_TOTAL_TOKENS.asString(), KeyValue.NONE_VALUE));
}
private Prompt generatePrompt() {
return new Prompt("Who let the dogs out?");
}
private AiOperationMetadata generateOperationMetadata() {
return AiOperationMetadata.builder()
.operationType(AiOperationType.CHAT.value())
.provider(AiProvider.OLLAMA.value())
.build();
}
static class TestUsage implements Usage {
@Override
public Long getPromptTokens() {
return 1000L;
}
@Override
public Long getGenerationTokens() {
return 500L;
}
}
}

View File

@@ -0,0 +1,149 @@
/*
* Copyright 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.embedding.observation;
import io.micrometer.common.KeyValue;
import io.micrometer.observation.Observation;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.embedding.EmbeddingOptions;
import org.springframework.ai.embedding.EmbeddingRequest;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.embedding.EmbeddingResponseMetadata;
import org.springframework.ai.observation.AiOperationMetadata;
import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation.HighCardinalityKeyNames;
import static org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation.LowCardinalityKeyNames;
/*
* Unit tests for {@link DefaultEmbeddingModelObservationConvention}.
*
* @author Thomas Vitale
*/
class DefaultEmbeddingModelObservationConventionTests {
private final DefaultEmbeddingModelObservationConvention observationConvention = new DefaultEmbeddingModelObservationConvention();
@Test
void shouldHaveName() {
assertThat(this.observationConvention.getName())
.isEqualTo(DefaultEmbeddingModelObservationConvention.DEFAULT_NAME);
}
@Test
void shouldHaveContextualName() {
EmbeddingModelObservationContext observationContext = EmbeddingModelObservationContext.builder()
.embeddingRequest(generateEmbeddingRequest())
.operationMetadata(generateOperationMetadata())
.requestOptions(EmbeddingModelRequestOptions.builder().model("mistral").build())
.build();
assertThat(this.observationConvention.getContextualName(observationContext)).isEqualTo("embedding mistral");
}
@Test
void supportsOnlyEmbeddingModelObservationContext() {
EmbeddingModelObservationContext observationContext = EmbeddingModelObservationContext.builder()
.embeddingRequest(generateEmbeddingRequest())
.operationMetadata(generateOperationMetadata())
.requestOptions(EmbeddingModelRequestOptions.builder().model("supermodel").build())
.build();
assertThat(this.observationConvention.supportsContext(observationContext)).isTrue();
assertThat(this.observationConvention.supportsContext(new Observation.Context())).isFalse();
}
@Test
void shouldHaveRequiredLowCardinalityKeyValues() {
EmbeddingModelObservationContext observationContext = EmbeddingModelObservationContext.builder()
.embeddingRequest(generateEmbeddingRequest())
.operationMetadata(generateOperationMetadata())
.requestOptions(EmbeddingModelRequestOptions.builder().model("mistral").build())
.build();
assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext)).contains(
KeyValue.of(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), "embedding"),
KeyValue.of(LowCardinalityKeyNames.AI_PROVIDER.asString(), "ollama"),
KeyValue.of(LowCardinalityKeyNames.REQUEST_MODEL.asString(), "mistral"));
}
@Test
void shouldHaveOptionalKeyValues() {
EmbeddingModelObservationContext observationContext = EmbeddingModelObservationContext.builder()
.embeddingRequest(generateEmbeddingRequest())
.operationMetadata(generateOperationMetadata())
.requestOptions(EmbeddingModelRequestOptions.builder()
.model("supermodel")
.dimensions(1492)
.encodingFormat("vector")
.build())
.build();
observationContext.setResponse(new EmbeddingResponse(List.of(),
new EmbeddingResponseMetadata("mistral-42", new TestUsage(), Map.of())));
assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext))
.contains(KeyValue.of(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), "mistral-42"));
assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext)).contains(
KeyValue.of(HighCardinalityKeyNames.REQUEST_EMBEDDING_DIMENSIONS.asString(), "1492"),
KeyValue.of(HighCardinalityKeyNames.REQUEST_EMBEDDING_ENCODING_FORMAT.asString(), "vector"),
KeyValue.of(HighCardinalityKeyNames.USAGE_INPUT_TOKENS.asString(), "1000"),
KeyValue.of(HighCardinalityKeyNames.USAGE_TOTAL_TOKENS.asString(), "1000"));
}
@Test
void shouldHaveMissingKeyValues() {
EmbeddingModelObservationContext observationContext = EmbeddingModelObservationContext.builder()
.embeddingRequest(generateEmbeddingRequest())
.operationMetadata(generateOperationMetadata())
.requestOptions(EmbeddingModelRequestOptions.builder().model("supermodel").build())
.build();
assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext))
.contains(KeyValue.of(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), KeyValue.NONE_VALUE));
assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext)).contains(
KeyValue.of(HighCardinalityKeyNames.REQUEST_EMBEDDING_DIMENSIONS.asString(), KeyValue.NONE_VALUE),
KeyValue.of(HighCardinalityKeyNames.REQUEST_EMBEDDING_ENCODING_FORMAT.asString(), KeyValue.NONE_VALUE),
KeyValue.of(HighCardinalityKeyNames.USAGE_INPUT_TOKENS.asString(), KeyValue.NONE_VALUE),
KeyValue.of(HighCardinalityKeyNames.USAGE_TOTAL_TOKENS.asString(), KeyValue.NONE_VALUE));
}
private EmbeddingRequest generateEmbeddingRequest() {
return new EmbeddingRequest(List.of(), EmbeddingOptions.EMPTY);
}
private AiOperationMetadata generateOperationMetadata() {
return AiOperationMetadata.builder()
.operationType(AiOperationType.EMBEDDING.value())
.provider(AiProvider.OLLAMA.value())
.build();
}
static class TestUsage implements Usage {
@Override
public Long getPromptTokens() {
return 1000L;
}
@Override
public Long getGenerationTokens() {
return 0L;
}
}
}

View File

@@ -0,0 +1,126 @@
/*
* Copyright 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.embedding.observation;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.embedding.EmbeddingOptions;
import org.springframework.ai.embedding.EmbeddingRequest;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.embedding.EmbeddingResponseMetadata;
import org.springframework.ai.observation.AiOperationMetadata;
import org.springframework.ai.observation.conventions.*;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation.LowCardinalityKeyNames;
/**
* Unit tests for {@link EmbeddingModelMeterObservationHandler}.
*
* @author Thomas Vitale
*/
class EmbeddingModelMeterObservationHandlerTests {
private MeterRegistry meterRegistry;
private ObservationRegistry observationRegistry;
@BeforeEach
void setUp() {
this.meterRegistry = new SimpleMeterRegistry();
this.observationRegistry = ObservationRegistry.create();
this.observationRegistry.observationConfig()
.observationHandler(new EmbeddingModelMeterObservationHandler(this.meterRegistry));
}
@Test
void shouldCreateAllMetersDuringAnObservation() {
var observationContext = generateObservationContext();
var observation = Observation
.createNotStarted(new DefaultEmbeddingModelObservationConvention(), () -> observationContext,
observationRegistry)
.start();
observationContext.setResponse(new EmbeddingResponse(List.of(),
new EmbeddingResponseMetadata("mistral-42", new TestUsage(), Map.of())));
observation.stop();
assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()).meters()).hasSize(3);
assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value())
.tag(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), AiOperationType.EMBEDDING.value())
.tag(LowCardinalityKeyNames.AI_PROVIDER.asString(), AiProvider.OLLAMA.value())
.tag(LowCardinalityKeyNames.REQUEST_MODEL.asString(), "mistral")
.tag(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), "mistral-42")
.meters()).hasSize(3);
assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value())
.tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.INPUT.value())
.meters()).hasSize(1);
assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value())
.tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.OUTPUT.value())
.meters()).hasSize(1);
assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value())
.tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.TOTAL.value())
.meters()).hasSize(1);
}
private EmbeddingModelObservationContext generateObservationContext() {
return EmbeddingModelObservationContext.builder()
.embeddingRequest(generateEmbeddingRequest())
.operationMetadata(generateOperationMetadata())
.requestOptions(EmbeddingModelRequestOptions.builder().model("mistral").build())
.build();
}
private EmbeddingRequest generateEmbeddingRequest() {
return new EmbeddingRequest(List.of(), EmbeddingOptions.EMPTY);
}
private AiOperationMetadata generateOperationMetadata() {
return AiOperationMetadata.builder()
.operationType(AiOperationType.EMBEDDING.value())
.provider(AiProvider.OLLAMA.value())
.build();
}
static class TestUsage implements Usage {
@Override
public Long getPromptTokens() {
return 1000L;
}
@Override
public Long getGenerationTokens() {
return 0L;
}
@Override
public Long getTotalTokens() {
return 1000L;
}
}
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright 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.embedding.observation;
import org.junit.jupiter.api.Test;
import org.springframework.ai.embedding.EmbeddingOptions;
import org.springframework.ai.embedding.EmbeddingRequest;
import org.springframework.ai.observation.AiOperationMetadata;
import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link EmbeddingModelObservationContext}.
*
* @author Thomas Vitale
*/
class EmbeddingModelObservationContextTests {
@Test
void whenMandatoryRequestOptionsThenReturn() {
var observationContext = EmbeddingModelObservationContext.builder()
.embeddingRequest(generateEmbeddingRequest())
.operationMetadata(generateOperationMetadata())
.requestOptions(EmbeddingModelRequestOptions.builder().model("supermodel").build())
.build();
assertThat(observationContext).isNotNull();
}
@Test
void whenRequestOptionsIsNullThenThrow() {
assertThatThrownBy(() -> EmbeddingModelObservationContext.builder()
.embeddingRequest(generateEmbeddingRequest())
.operationMetadata(generateOperationMetadata())
.requestOptions(null)
.build()).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("requestOptions cannot be null");
}
private EmbeddingRequest generateEmbeddingRequest() {
return new EmbeddingRequest(List.of(), EmbeddingOptions.EMPTY);
}
private AiOperationMetadata generateOperationMetadata() {
return AiOperationMetadata.builder()
.operationType(AiOperationType.EMBEDDING.value())
.provider(AiProvider.OLLAMA.value())
.build();
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright 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.embedding.observation;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link EmbeddingModelRequestOptions}.
*
* @author Thomas Vitale
*/
class EmbeddingModelRequestOptionsTests {
@Test
void whenMandatoryRequestOptionsThenReturn() {
var requestOptions = EmbeddingModelRequestOptions.builder().model("rowena").build();
assertThat(requestOptions).isNotNull();
}
@Test
void whenModelIsNullThenThrow() {
assertThatThrownBy(() -> EmbeddingModelRequestOptions.builder().build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("model cannot be null or empty");
}
@Test
void whenModelIsEmptyThenThrow() {
assertThatThrownBy(() -> EmbeddingModelRequestOptions.builder().model("").build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("model cannot be null or empty");
}
}

View File

@@ -0,0 +1,123 @@
/*
* Copyright 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.image.observation;
import io.micrometer.common.KeyValue;
import io.micrometer.observation.Observation;
import org.junit.jupiter.api.Test;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.observation.AiOperationMetadata;
import org.springframework.ai.observation.conventions.AiObservationAttributes;
import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link DefaultImageModelObservationConvention}.
*
* @author Thomas Vitale
*/
class DefaultImageModelObservationConventionTests {
private final DefaultImageModelObservationConvention observationConvention = new DefaultImageModelObservationConvention();
@Test
void shouldHaveName() {
assertThat(this.observationConvention.getName()).isEqualTo(DefaultImageModelObservationConvention.DEFAULT_NAME);
}
@Test
void shouldHaveContextualName() {
ImageModelObservationContext observationContext = ImageModelObservationContext.builder()
.imagePrompt(generateImagePrompt())
.operationMetadata(generateOperationMetadata())
.requestOptions(ImageModelRequestOptions.builder().model("mistral").build())
.build();
assertThat(this.observationConvention.getContextualName(observationContext)).isEqualTo("image mistral");
}
@Test
void supportsOnlyImageModelObservationContext() {
ImageModelObservationContext observationContext = ImageModelObservationContext.builder()
.imagePrompt(generateImagePrompt())
.operationMetadata(generateOperationMetadata())
.requestOptions(ImageModelRequestOptions.builder().model("mistral").build())
.build();
assertThat(this.observationConvention.supportsContext(observationContext)).isTrue();
assertThat(this.observationConvention.supportsContext(new Observation.Context())).isFalse();
}
@Test
void shouldHaveRequiredLowCardinalityKeyValues() {
ImageModelObservationContext observationContext = ImageModelObservationContext.builder()
.imagePrompt(generateImagePrompt())
.operationMetadata(generateOperationMetadata())
.requestOptions(ImageModelRequestOptions.builder().model("mistral").build())
.build();
assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext)).contains(
KeyValue.of(AiObservationAttributes.AI_OPERATION_TYPE.value(), "image"),
KeyValue.of(AiObservationAttributes.AI_PROVIDER.value(), "ollama"),
KeyValue.of(AiObservationAttributes.REQUEST_MODEL.value(), "mistral"));
}
@Test
void shouldHaveOptionalHighCardinalityKeyValues() {
ImageModelObservationContext observationContext = ImageModelObservationContext.builder()
.imagePrompt(generateImagePrompt())
.operationMetadata(generateOperationMetadata())
.requestOptions(ImageModelRequestOptions.builder()
.model("mistral")
.n(1)
.height(1080)
.width(1920)
.style("sketch")
.responseFormat("base64")
.build())
.build();
assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext)).contains(
KeyValue.of(AiObservationAttributes.REQUEST_IMAGE_RESPONSE_FORMAT.value(), "base64"),
KeyValue.of(AiObservationAttributes.REQUEST_IMAGE_SIZE.value(), "1920x1080"),
KeyValue.of(AiObservationAttributes.REQUEST_IMAGE_STYLE.value(), "sketch"));
}
@Test
void shouldHaveMissingHighCardinalityKeyValues() {
ImageModelObservationContext observationContext = ImageModelObservationContext.builder()
.imagePrompt(generateImagePrompt())
.operationMetadata(generateOperationMetadata())
.requestOptions(ImageModelRequestOptions.builder().model("mistral").build())
.build();
assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext)).contains(
KeyValue.of(AiObservationAttributes.REQUEST_IMAGE_RESPONSE_FORMAT.value(), KeyValue.NONE_VALUE),
KeyValue.of(AiObservationAttributes.REQUEST_IMAGE_SIZE.value(), KeyValue.NONE_VALUE),
KeyValue.of(AiObservationAttributes.REQUEST_IMAGE_STYLE.value(), KeyValue.NONE_VALUE));
}
private ImagePrompt generateImagePrompt() {
return new ImagePrompt("here comes the sun");
}
private AiOperationMetadata generateOperationMetadata() {
return AiOperationMetadata.builder()
.operationType(AiOperationType.IMAGE.value())
.provider(AiProvider.OLLAMA.value())
.build();
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright 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.image.observation;
import org.junit.jupiter.api.Test;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.observation.AiOperationMetadata;
import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link ImageModelObservationContext}.
*
* @author Thomas Vitale
*/
class ImageModelObservationContextTests {
@Test
void whenMandatoryRequestOptionsThenReturn() {
var observationContext = ImageModelObservationContext.builder()
.imagePrompt(generateImagePrompt())
.operationMetadata(generateOperationMetadata())
.requestOptions(ImageModelRequestOptions.builder().model("supersun").build())
.build();
assertThat(observationContext).isNotNull();
}
@Test
void whenRequestOptionsIsNullThenThrow() {
assertThatThrownBy(() -> ImageModelObservationContext.builder()
.imagePrompt(generateImagePrompt())
.operationMetadata(generateOperationMetadata())
.requestOptions(null)
.build()).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("requestOptions cannot be null");
}
private ImagePrompt generateImagePrompt() {
return new ImagePrompt("here comes the sun");
}
private AiOperationMetadata generateOperationMetadata() {
return AiOperationMetadata.builder()
.operationType(AiOperationType.IMAGE.value())
.provider(AiProvider.OLLAMA.value())
.build();
}
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright 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.image.observation;
import io.micrometer.common.KeyValue;
import io.micrometer.observation.Observation;
import org.junit.jupiter.api.Test;
import org.springframework.ai.image.ImageMessage;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.observation.AiOperationMetadata;
import org.springframework.ai.observation.conventions.AiObservationAttributes;
import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link ImageModelPromptContentObservationFilter}.
*
* @author Thomas Vitale
*/
class ImageModelPromptContentObservationFilterTests {
private final ImageModelPromptContentObservationFilter observationFilter = new ImageModelPromptContentObservationFilter();
@Test
void whenNotSupportedObservationContextThenReturnOriginalContext() {
var expectedContext = new Observation.Context();
var actualContext = observationFilter.map(expectedContext);
assertThat(actualContext).isEqualTo(expectedContext);
}
@Test
void whenEmptyPromptThenReturnOriginalContext() {
var expectedContext = ImageModelObservationContext.builder()
.imagePrompt(new ImagePrompt(""))
.operationMetadata(generateOperationMetadata())
.requestOptions(ImageModelRequestOptions.builder().model("mistral").build())
.build();
var actualContext = observationFilter.map(expectedContext);
assertThat(actualContext).isEqualTo(expectedContext);
}
@Test
void whenPromptWithTextThenAugmentContext() {
var originalContext = ImageModelObservationContext.builder()
.imagePrompt(new ImagePrompt("supercalifragilisticexpialidocious"))
.operationMetadata(generateOperationMetadata())
.requestOptions(ImageModelRequestOptions.builder().model("mistral").build())
.build();
var augmentedContext = observationFilter.map(originalContext);
assertThat(augmentedContext.getHighCardinalityKeyValues())
.contains(KeyValue.of(AiObservationAttributes.PROMPT.value(), "[\"supercalifragilisticexpialidocious\"]"));
}
@Test
void whenPromptWithMessagesThenAugmentContext() {
var originalContext = ImageModelObservationContext.builder()
.imagePrompt(new ImagePrompt(List.of(new ImageMessage("you're a chimney sweep"),
new ImageMessage("supercalifragilisticexpialidocious"))))
.operationMetadata(generateOperationMetadata())
.requestOptions(ImageModelRequestOptions.builder().model("mistral").build())
.build();
var augmentedContext = observationFilter.map(originalContext);
assertThat(augmentedContext.getHighCardinalityKeyValues())
.contains(KeyValue.of(AiObservationAttributes.PROMPT.value(),
"[\"you're a chimney sweep\", \"supercalifragilisticexpialidocious\"]"));
}
private AiOperationMetadata generateOperationMetadata() {
return AiOperationMetadata.builder()
.operationType(AiOperationType.IMAGE.value())
.provider(AiProvider.OLLAMA.value())
.build();
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright 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.image.observation;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link ImageModelRequestOptions}.
*
* @author Thomas Vitale
*/
class ImageModelRequestOptionsTests {
@Test
void whenMandatoryRequestOptionsThenReturn() {
var requestOptions = ImageModelRequestOptions.builder().model("rowena").build();
assertThat(requestOptions).isNotNull();
}
@Test
void whenModelIsNullThenThrow() {
assertThatThrownBy(() -> ImageModelRequestOptions.builder().build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("model cannot be null or empty");
}
@Test
void whenModelIsEmptyThenThrow() {
assertThatThrownBy(() -> ImageModelRequestOptions.builder().model("").build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("model cannot be null or empty");
}
}

View File

@@ -0,0 +1,86 @@
package org.springframework.ai.model.observation;
import org.junit.jupiter.api.Test;
import org.springframework.ai.observation.AiOperationMetadata;
import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link ModelObservationContext}.
*
* @author Thomas Vitale
*/
class ModelObservationContextTests {
@Test
void whenRequestAndMetadataThenReturn() {
var observationContext = new ModelObservationContext<String, String>("test request",
AiOperationMetadata.builder()
.operationType(AiOperationType.CHAT.value())
.provider(AiProvider.OLLAMA.value())
.build());
assertThat(observationContext).isNotNull();
}
@Test
void whenRequestIsNullThenThrow() {
assertThatThrownBy(() -> new ModelObservationContext<String, String>(null,
AiOperationMetadata.builder()
.operationType(AiOperationType.EMBEDDING.value())
.provider(AiProvider.OLLAMA.value())
.build()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("request cannot be null");
}
@Test
void whenOperationMetadataIsNullThenThrow() {
assertThatThrownBy(() -> new ModelObservationContext<String, String>("test request", null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("operationMetadata cannot be null");
}
@Test
void whenOperationMetadataIsMissingOperationTypeThenThrow() {
assertThatThrownBy(() -> new ModelObservationContext<String, String>("test request",
AiOperationMetadata.builder().provider(AiProvider.OLLAMA.value()).build()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("operationType cannot be null or empty");
}
@Test
void whenOperationMetadataIsMissingProviderThenThrow() {
assertThatThrownBy(() -> new ModelObservationContext<String, String>("test request",
AiOperationMetadata.builder().operationType(AiOperationType.IMAGE.value()).build()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("provider cannot be null or empty");
}
@Test
void whenResponseThenReturn() {
var observationContext = new ModelObservationContext<String, String>("test request",
AiOperationMetadata.builder()
.operationType(AiOperationType.CHAT.value())
.provider(AiProvider.OLLAMA.value())
.build());
observationContext.setResponse("test response");
assertThat(observationContext).isNotNull();
}
@Test
void whenResponseIsNullThenThrow() {
var observationContext = new ModelObservationContext<String, String>("test request",
AiOperationMetadata.builder()
.operationType(AiOperationType.CHAT.value())
.provider(AiProvider.OLLAMA.value())
.build());
assertThatThrownBy(() -> observationContext.setResponse(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("response cannot be null");
}
}

View File

@@ -0,0 +1,112 @@
/*
* Copyright 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.model.observation;
import io.micrometer.common.KeyValue;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.micrometer.observation.Observation;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.observation.conventions.AiObservationMetricAttributes;
import org.springframework.ai.observation.conventions.AiObservationMetricNames;
import org.springframework.ai.observation.conventions.AiTokenType;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link ModelUsageMetricsGenerator}.
*
* @author Thomas Vitale
*/
class ModelUsageMetricsGeneratorTests {
@Test
void whenTokenUsageThenMetrics() {
var meterRegistry = new SimpleMeterRegistry();
var usage = new TestUsage(1000L, 500L, 1500L);
ModelUsageMetricsGenerator.generate(usage, buildContext(), meterRegistry);
assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()).meters()).hasSize(3);
assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value())
.tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.INPUT.value())
.counter()
.count()).isEqualTo(1000);
assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value())
.tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.OUTPUT.value())
.counter()
.count()).isEqualTo(500);
assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value())
.tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.TOTAL.value())
.counter()
.count()).isEqualTo(1500);
}
@Test
void whenPartialTokenUsageThenMetrics() {
var meterRegistry = new SimpleMeterRegistry();
var usage = new TestUsage(1000L, null, 1000L);
ModelUsageMetricsGenerator.generate(usage, buildContext(), meterRegistry);
assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()).meters()).hasSize(2);
assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value())
.tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.INPUT.value())
.counter()
.count()).isEqualTo(1000);
assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value())
.tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.TOTAL.value())
.counter()
.count()).isEqualTo(1000);
}
private Observation.Context buildContext() {
var context = new Observation.Context();
context.addLowCardinalityKeyValue(KeyValue.of("key1", "value1"));
context.addLowCardinalityKeyValue(KeyValue.of("key2", "value2"));
return context;
}
static class TestUsage implements Usage {
private final Long promptTokens;
private final Long generationTokens;
private final Long totalTokens;
public TestUsage(Long promptTokens, Long generationTokens, Long totalTokens) {
this.promptTokens = promptTokens;
this.generationTokens = generationTokens;
this.totalTokens = totalTokens;
}
@Override
public Long getPromptTokens() {
return promptTokens;
}
@Override
public Long getGenerationTokens() {
return generationTokens;
}
@Override
public Long getTotalTokens() {
return totalTokens;
}
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright 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.observation;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link AiOperationMetadata}.
*
* @author Thomas Vitale
*/
class AiOperationMetadataTests {
@Test
void whenMandatoryMetadataThenReturn() {
var operationMetadata = AiOperationMetadata.builder().operationType("chat").provider("doofenshmirtz").build();
assertThat(operationMetadata).isNotNull();
}
@Test
void whenOperationTypeIsNullThenThrow() {
assertThatThrownBy(() -> AiOperationMetadata.builder().provider("doofenshmirtz").build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("operationType cannot be null or empty");
}
@Test
void whenOperationTypeIsEmptyThenThrow() {
assertThatThrownBy(() -> AiOperationMetadata.builder().operationType("").provider("doofenshmirtz").build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("operationType cannot be null or empty");
}
@Test
void whenProviderIsNullThenThrow() {
assertThatThrownBy(() -> AiOperationMetadata.builder().operationType("chat").build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("provider cannot be null or empty");
}
@Test
void whenProviderIsEmptyThenThrow() {
assertThatThrownBy(() -> AiOperationMetadata.builder().operationType("chat").provider("").build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("provider cannot be null or empty");
}
}