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:
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user