Files
2026-07-13 13:22:34 +08:00

641 lines
20 KiB
Python

from unittest import mock
import pytest
from aiohttp import ClientTimeout
from fastapi.encoders import jsonable_encoder
from pydantic import ValidationError
from mlflow.environment_variables import MLFLOW_GATEWAY_ROUTE_TIMEOUT_SECONDS
from mlflow.gateway.config import EndpointConfig
from mlflow.gateway.exceptions import AIGatewayException
from mlflow.gateway.providers.cohere import CohereProvider
from mlflow.gateway.schemas import chat, completions, embeddings
from tests.gateway.tools import MockAsyncResponse, MockAsyncStreamingResponse
def chat_config():
return {
"name": "chat",
"endpoint_type": "llm/v1/chat",
"model": {
"provider": "cohere",
"name": "command",
"config": {
"cohere_api_key": "key",
},
},
}
def chat_response():
return {
"response_id": "abc123",
"text": "\n\nThis is a test!",
"generation_id": "def456",
"token_count": {
"prompt_tokens": 13,
"response_tokens": 7,
"total_tokens": 20,
"billed_tokens": 20,
},
"meta": {
"api_version": {"version": "1"},
"billed_units": {"input_tokens": 13, "output_tokens": 7},
},
"tool_inputs": None,
}
def chat_payload(stream: bool = False):
payload = {
"messages": [
{"role": "user", "content": "Message 1"},
{"role": "assistant", "content": "Message 2"},
{"role": "user", "content": "Message 3"},
],
"temperature": 0.5,
}
if stream:
payload["stream"] = True
return payload
@pytest.mark.asyncio
async def test_chat():
resp = chat_response()
config = chat_config()
with (
mock.patch("time.time", return_value=1677858242),
mock.patch("aiohttp.ClientSession.post", return_value=MockAsyncResponse(resp)) as mock_post,
):
provider = CohereProvider(EndpointConfig(**config))
payload = chat_payload()
response = await provider.chat(chat.RequestPayload(**payload))
assert jsonable_encoder(response) == {
"id": "abc123",
"object": "chat.completion",
"created": 1677858242,
"model": "command",
"provider": "cohere",
"choices": [
{
"message": {
"role": "assistant",
"content": "\n\nThis is a test!",
"tool_calls": None,
"refusal": None,
},
"finish_reason": None,
"index": 0,
}
],
"usage": {
"prompt_tokens": 13,
"completion_tokens": 7,
"total_tokens": 20,
},
}
mock_post.assert_called_once_with(
"https://api.cohere.ai/v1/chat",
json={
"model": "command",
"chat_history": [
{"role": "USER", "message": "Message 1"},
{"role": "CHATBOT", "message": "Message 2"},
],
"message": "Message 3",
"temperature": 1.25,
},
timeout=ClientTimeout(total=MLFLOW_GATEWAY_ROUTE_TIMEOUT_SECONDS.get()),
)
@pytest.mark.asyncio
async def test_chat_with_system_messages():
resp = chat_response()
config = chat_config()
with (
mock.patch("time.time", return_value=1677858242),
mock.patch("aiohttp.ClientSession.post", return_value=MockAsyncResponse(resp)) as mock_post,
):
provider = CohereProvider(EndpointConfig(**config))
payload = {
"messages": [
{"role": "system", "content": "System Message 1"},
{"role": "user", "content": "Message 1"},
{"role": "assistant", "content": "Message 2"},
{"role": "system", "content": "System Message 2"},
{"role": "user", "content": "Message 3"},
],
"temperature": 0.5,
}
await provider.chat(chat.RequestPayload(**payload))
mock_post.assert_called_once_with(
"https://api.cohere.ai/v1/chat",
json={
"model": "command",
"chat_history": [
{"role": "USER", "message": "Message 1"},
{"role": "CHATBOT", "message": "Message 2"},
],
"message": "Message 3",
"preamble_override": "System Message 1\nSystem Message 2",
"temperature": 1.25,
},
timeout=ClientTimeout(total=MLFLOW_GATEWAY_ROUTE_TIMEOUT_SECONDS.get()),
)
@pytest.mark.parametrize(
"params",
[{"n": 2}, {"stop": ["test"]}],
)
@pytest.mark.asyncio
async def test_chat_throws_if_parameter_not_permitted(params):
config = chat_config()
provider = CohereProvider(EndpointConfig(**config))
payload = chat_payload()
payload.update(params)
with pytest.raises(AIGatewayException, match=r".*") as e:
await provider.chat(chat.RequestPayload(**payload))
assert e.value.status_code == 422
def chat_stream_response():
return [
# first chunk
b'{"is_finished":false,"event_type":"stream-start","generation_id":"test-id2"}',
# subsequent chunks
b'{"text":" Hi","is_finished":false,"event_type":"text-generation"}',
b'{"text":" there","is_finished":false,"event_type":"text-generation"}',
# final chunk
b'{"is_finished":true,"event_type":"stream-end","response":{"response_id":"test-id1",'
b'"text":"How are you","generation_id":"test-id2","token_count":{"prompt_tokens":83,'
b'"response_tokens":63,"total_tokens":146,"billed_tokens":128},"tool_inputs":null},'
b'"finish_reason":"COMPLETE"}',
]
@pytest.mark.asyncio
async def test_chat_stream():
resp = chat_stream_response()
config = chat_config()
with (
mock.patch("time.time", return_value=1677858242),
mock.patch(
"aiohttp.ClientSession.post", return_value=MockAsyncStreamingResponse(resp)
) as mock_post,
):
provider = CohereProvider(EndpointConfig(**config))
payload = chat_payload(stream=True)
response = provider.chat_stream(chat.RequestPayload(**payload))
chunks = [jsonable_encoder(chunk) async for chunk in response]
assert chunks == [
{
"choices": [
{
"delta": {
"role": None,
"content": " Hi",
"tool_calls": None,
},
"finish_reason": None,
"index": 0,
}
],
"created": 1677858242,
"id": None,
"model": "command",
"provider": "cohere",
"object": "chat.completion.chunk",
"usage": {
"prompt_tokens": None,
"completion_tokens": None,
"total_tokens": None,
},
},
{
"choices": [
{
"delta": {
"role": None,
"content": " there",
"tool_calls": None,
},
"finish_reason": None,
"index": 0,
}
],
"created": 1677858242,
"id": None,
"model": "command",
"provider": "cohere",
"object": "chat.completion.chunk",
"usage": {
"prompt_tokens": None,
"completion_tokens": None,
"total_tokens": None,
},
},
{
"choices": [
{
"delta": {
"role": None,
"content": None,
"tool_calls": None,
},
"finish_reason": "COMPLETE",
"index": 0,
}
],
"created": 1677858242,
"id": "test-id1",
"model": "command",
"provider": "cohere",
"object": "chat.completion.chunk",
"usage": {
"prompt_tokens": 83,
"completion_tokens": 63,
"total_tokens": 146,
},
},
]
mock_post.assert_called_once_with(
"https://api.cohere.ai/v1/chat",
json={
"model": "command",
"chat_history": [
{"role": "USER", "message": "Message 1"},
{"role": "CHATBOT", "message": "Message 2"},
],
"message": "Message 3",
"temperature": 1.25,
"stream": True,
},
timeout=ClientTimeout(total=MLFLOW_GATEWAY_ROUTE_TIMEOUT_SECONDS.get()),
)
def completions_config():
return {
"name": "completions",
"endpoint_type": "llm/v1/completions",
"model": {
"provider": "cohere",
"name": "command",
"config": {
"cohere_api_key": "key",
},
},
}
def completions_response():
return {
"id": "string",
"generations": [
{
"id": "string",
"text": "This is a test",
}
],
"prompt": "string",
"headers": {"Content-Type": "application/json"},
}
@pytest.mark.asyncio
async def test_completions():
resp = completions_response()
config = completions_config()
with (
mock.patch("time.time", return_value=1677858242),
mock.patch("aiohttp.ClientSession.post", return_value=MockAsyncResponse(resp)) as mock_post,
):
provider = CohereProvider(EndpointConfig(**config))
payload = {
"prompt": "This is a test",
"n": 1,
"stop": ["foobar"],
}
response = await provider.completions(completions.RequestPayload(**payload))
assert jsonable_encoder(response) == {
"id": None,
"object": "text_completion",
"created": 1677858242,
"model": "command",
"choices": [
{
"text": "This is a test",
"index": 0,
"finish_reason": None,
}
],
"usage": {"prompt_tokens": None, "completion_tokens": None, "total_tokens": None},
}
mock_post.assert_called_once_with(
"https://api.cohere.ai/v1/generate",
json={
"prompt": "This is a test",
"model": "command",
"num_generations": 1,
"stop_sequences": ["foobar"],
},
timeout=ClientTimeout(total=MLFLOW_GATEWAY_ROUTE_TIMEOUT_SECONDS.get()),
)
@pytest.mark.asyncio
async def test_completions_temperature_is_scaled_correctly():
resp = completions_response()
config = completions_config()
with mock.patch(
"aiohttp.ClientSession.post", return_value=MockAsyncResponse(resp)
) as mock_post:
provider = CohereProvider(EndpointConfig(**config))
payload = {
"prompt": "This is a test",
"temperature": 0.5,
}
await provider.completions(completions.RequestPayload(**payload))
assert mock_post.call_args[1]["json"]["temperature"] == 0.5 * 2.5
def completions_stream_response():
return [
b'{"text":" Hi","is_finished":false,"event_type":"text-generation"}',
b'{"text":" there","is_finished":false,"event_type":"text-generation"}',
# final chunk
b'{"is_finished":true,"event_type":"stream-end","finish_reason":"COMPLETE",'
b'"response":{"id":"test-id1","generations":'
b'[{"id":"test-id2","text":" Hi there","finish_reason":"COMPLETE"}],'
b'"prompt":"This is a test"}}',
]
@pytest.mark.asyncio
async def test_completions_stream():
resp = completions_stream_response()
config = completions_config()
with (
mock.patch("time.time", return_value=1677858242),
mock.patch(
"aiohttp.ClientSession.post", return_value=MockAsyncStreamingResponse(resp)
) as mock_post,
):
provider = CohereProvider(EndpointConfig(**config))
payload = {
"prompt": "This is a test",
"n": 1,
"stream": True,
}
response = provider.completions_stream(completions.RequestPayload(**payload))
chunks = [jsonable_encoder(chunk) async for chunk in response]
assert chunks == [
{
"choices": [
{
"text": " Hi",
"finish_reason": None,
"index": 0,
}
],
"created": 1677858242,
"id": None,
"model": "command",
"object": "text_completion_chunk",
"usage": {
"prompt_tokens": None,
"completion_tokens": None,
"total_tokens": None,
},
},
{
"choices": [
{
"text": " there",
"finish_reason": None,
"index": 0,
}
],
"created": 1677858242,
"id": None,
"model": "command",
"object": "text_completion_chunk",
"usage": {
"prompt_tokens": None,
"completion_tokens": None,
"total_tokens": None,
},
},
{
"choices": [
{
"text": None,
"finish_reason": "COMPLETE",
"index": 0,
}
],
"created": 1677858242,
"id": "test-id1",
"model": "command",
"object": "text_completion_chunk",
"usage": {
"prompt_tokens": None,
"completion_tokens": None,
"total_tokens": None,
},
},
]
mock_post.assert_called_once_with(
"https://api.cohere.ai/v1/generate",
json={
"prompt": "This is a test",
"model": "command",
"num_generations": 1,
"stream": True,
},
timeout=ClientTimeout(total=MLFLOW_GATEWAY_ROUTE_TIMEOUT_SECONDS.get()),
)
def embeddings_config():
return {
"name": "embeddings",
"endpoint_type": "llm/v1/embeddings",
"model": {
"provider": "cohere",
"name": "embed-english-light-v2.0",
"config": {
"cohere_api_key": "key",
},
},
}
def embeddings_response():
return {
"id": "bc57846a-3e56-4327-8acc-588ca1a37b8a",
"texts": ["hello world"],
"embeddings": [
[
3.25,
0.7685547,
2.65625,
-0.30126953,
-2.3554688,
1.2597656,
]
],
"meta": [
{
"api_version": [
{
"version": "1",
}
]
},
],
"headers": {"Content-Type": "application/json"},
}
def embeddings_batch_response():
return {
"id": "bc57846a-3e56-4327-8acc-588ca1a37b8a",
"texts": ["hello world"],
"embeddings": [
[
3.25,
0.7685547,
2.65625,
-0.30126953,
-2.3554688,
1.2597656,
],
[
7.25,
0.7685547,
4.65625,
-0.30126953,
-2.3554688,
8.2597656,
],
],
"meta": [
{
"api_version": [
{
"version": "1",
}
]
},
],
"headers": {"Content-Type": "application/json"},
}
@pytest.mark.asyncio
async def test_embeddings():
resp = embeddings_response()
config = embeddings_config()
with mock.patch(
"aiohttp.ClientSession.post", return_value=MockAsyncResponse(resp)
) as mock_post:
provider = CohereProvider(EndpointConfig(**config))
payload = {"input": "This is a test"}
response = await provider.embeddings(embeddings.RequestPayload(**payload))
assert jsonable_encoder(response) == {
"object": "list",
"data": [
{
"object": "embedding",
"embedding": [
3.25,
0.7685547,
2.65625,
-0.30126953,
-2.3554688,
1.2597656,
],
"index": 0,
}
],
"model": "embed-english-light-v2.0",
"usage": {"prompt_tokens": None, "total_tokens": None},
}
mock_post.assert_called_once()
@pytest.mark.asyncio
async def test_batch_embeddings():
resp = embeddings_batch_response()
config = embeddings_config()
with mock.patch(
"aiohttp.ClientSession.post", return_value=MockAsyncResponse(resp)
) as mock_post:
provider = CohereProvider(EndpointConfig(**config))
payload = {"input": ["This is a", "batch test"]}
response = await provider.embeddings(embeddings.RequestPayload(**payload))
assert jsonable_encoder(response) == {
"object": "list",
"data": [
{
"object": "embedding",
"embedding": [
3.25,
0.7685547,
2.65625,
-0.30126953,
-2.3554688,
1.2597656,
],
"index": 0,
},
{
"object": "embedding",
"embedding": [
7.25,
0.7685547,
4.65625,
-0.30126953,
-2.3554688,
8.2597656,
],
"index": 1,
},
],
"model": "embed-english-light-v2.0",
"usage": {"prompt_tokens": None, "total_tokens": None},
}
mock_post.assert_called_once()
@pytest.mark.asyncio
async def test_param_model_is_not_permitted():
config = embeddings_config()
provider = CohereProvider(EndpointConfig(**config))
payload = {
"prompt": "This should fail",
"max_tokens": 5000,
"model": "something-else",
}
with pytest.raises(AIGatewayException, match=r".*") as e:
await provider.completions(completions.RequestPayload(**payload))
assert "The parameter 'model' is not permitted" in e.value.detail
assert e.value.status_code == 422
@pytest.mark.parametrize("prompt", [{"set1", "set2"}, ["list1"], [1], ["list1", "list2"], [1, 2]])
@pytest.mark.asyncio
async def test_completions_throws_if_prompt_contains_non_string(prompt):
config = completions_config()
provider = CohereProvider(EndpointConfig(**config))
payload = {"prompt": prompt}
with pytest.raises(ValidationError, match=r"prompt"):
await provider.completions(completions.RequestPayload(**payload))