mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-13 03:59:12 +00:00
* test: migrate local_testing SDK and provider tests out of build_and_test * test: cover sagemaker region, hf template and nova paths end to end Region rows now go through litellm.completion on the sagemaker/ handler that the legacy tests exercised, the prompt template test mocks the Hugging Face tokenizer config instead of switching to custom_prompt_dict, streaming tests assert role, finish reason and body, and Nova single turn and streaming run through litellm.completion with a recorded request body * test: tighten migrated integration tests for local OpenAI routes, timeouts and air-gapped boot Split the catch-all providers migration file by behavior. The fake endpoint route tests now hit the owned upstream directly, as the legacy harness tests did. LM Studio asserts the outbound key, path and stripped model. The embedding timeout test asserts the proxy maps a deployment timeout to a 408 and forwards the read timeout to the provider client, instead of timing out the test client The Azure streaming tool-call test also asserts the second identical stream is served from the Redis cache with the same reassembled tool call The Docker network test always skipped in CI because the security job has no litellm/litellm image and no egress to pull one, and it tested a published image rather than the checkout. It now boots the checkout proxy with every outbound HTTP connection refused and asserts readiness, the bundled cost map fallback and that the cost map host is the only egress attempted * test: tighten the migrated e2e rows for local_testing SDK and provider tests Drop the duplicated Anthropic covers marker and the Anthropic cell wrongly claimed by the Bedrock and Vertex cache tests. Keep the file-wide Anthropic model as it is on main. Send the 2023-06-01 version header to Anthropic through the deployment's extra_headers so the header actually reaches the provider, and mark the creation-token case 1h then 5m like the legacy test did Reuse the Bedrock file's own registration for the pydantic response test, swap the hand-built PNGs for the shared cat.jpg fixture, assert the SageMaker Nova image answer names the cat and that top_logprobs=3 is honored, and register the SageMaker Nova opt-in markers the same way as the other opt-in markers * test: restore lost assertions in migrated test_main tests Restore the role alternation check in test_completion_anthropic_hanging, the embedding and streaming response-header checks, the logit_bias drop check and the gemini system and safety settings checks. Add an azure 429 retry-after test for test_exception_with_headers, trigger the router fallback with mock_timeout like the legacy test, and stop the mock timeout tests sleeping 3s * test: restore lost legacy assertions in migrated vertex ai unit tests * test: tighten migrated cost, streaming and embedding tests and scope the hosted mock guard * test: strengthen migrated anthropic, azure, bedrock and cohere provider tests Assert full request bodies and usage instead of presence checks, restore the dummy-tool and custom_llm_provider assertions the legacy tests made, add the async and per-model variants the legacy parametrization covered, split streamed tool arguments across deltas, and move the Azure chat streaming tests to the azure.py mirror file * test: tighten migrated llms provider tests and cover legacy intents lost in the move Strengthen the migrated fireworks, gemini, mistral, together, openai text completion, openrouter, perplexity and openai error tests so they assert what the legacy tests proved (cost, provider tag, usage-only-on-last-chunk, function_call rebuild, reasoning param, sync/async/stream error fields). Replace the huggingface and jina tests that doubled litellm code with respx end-to-end tests. Add the tests the pre-existing mapped tests never covered: cohere 400 mapping, token-array text completion, hosted_vllm/llamafile/lm_studio env api base embeddings, watsonx embedding auth, ollama ssl_verify client and ollama vision prompt * test: cover residual legacy paths for anthropic tool streams, watsonx async stream, ollama http images and caller async embedding clients * test: restore single request assertions in the deep_infra and azure_ai embedding tests * test: drop a no-op streaming cost flag from the openai streaming cost test * test: cover residual legacy-test lines in caching, main, utils, replicate, vertex and bedrock invoke * test: cover streamed audio assembly, choice membership, untranslated responses events and the stream log guard * test: pin cost rates in unit tests, use sdk for cohere e2e, drop vertex llama e2e * test: send the vertex maps e2e request through the openai sdk * test: drop the local_testing jobs that select no tests after the migrations * test: wait for the streamed cache entry and keep custom Mistral rates test-local * test: keep untouched Mistral test lines as they were --------- Co-authored-by: yuneng <yuneng@berri.ai>
620 lines
23 KiB
Python
620 lines
23 KiB
Python
"""
|
||
Regression tests for LIT-4313: the native `sagemaker/` streaming path must
|
||
forward each AWS event-stream frame as it arrives instead of buffering to a
|
||
fixed 1024-byte threshold and then draining a burst of tokens.
|
||
|
||
The buffering came from `response.aiter_bytes(chunk_size=1024)`: httpx's
|
||
ByteChunker withholds bytes until `chunk_size` accumulates, so the first token
|
||
could not be produced until enough later frames had arrived to cross 1024 bytes,
|
||
inflating TTFT and turning a steady provider stream into gap-then-burst delivery.
|
||
"""
|
||
|
||
import binascii
|
||
import json
|
||
import struct
|
||
from typing import AsyncIterator, Final, Iterator
|
||
from unittest.mock import MagicMock
|
||
|
||
import httpx
|
||
import litellm
|
||
import pytest
|
||
import respx
|
||
|
||
from litellm.llms.sagemaker.common_utils import SagemakerError
|
||
from litellm.llms.sagemaker.completion.handler import SagemakerLLM
|
||
|
||
|
||
@pytest.fixture
|
||
def httpx_transport(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||
yield
|
||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||
|
||
|
||
def _encode_header(name: str, value: str) -> bytes:
|
||
name_b = name.encode("utf-8")
|
||
value_b = value.encode("utf-8")
|
||
return struct.pack("B", len(name_b)) + name_b + struct.pack("B", 7) + struct.pack(">H", len(value_b)) + value_b
|
||
|
||
|
||
def _encode_event_frame(payload: bytes) -> bytes:
|
||
"""Encode one AWS event-stream message that botocore's EventStreamBuffer decodes."""
|
||
headers = {
|
||
":event-type": "PayloadPart",
|
||
":content-type": "application/json",
|
||
":message-type": "event",
|
||
}
|
||
headers_b = b"".join(_encode_header(k, v) for k, v in headers.items())
|
||
total_len = 16 + len(headers_b) + len(payload)
|
||
prelude = struct.pack(">I", total_len) + struct.pack(">I", len(headers_b))
|
||
prelude_crc = struct.pack(">I", binascii.crc32(prelude) & 0xFFFFFFFF)
|
||
message = prelude + prelude_crc + headers_b + payload
|
||
message_crc = struct.pack(">I", binascii.crc32(message) & 0xFFFFFFFF)
|
||
return message + message_crc
|
||
|
||
|
||
def _token_frame(text: str) -> bytes:
|
||
# SageMaker HF TGI streaming payloads are `{"token": {"text": ...}}` blobs.
|
||
sse = "data: " + json.dumps({"token": {"text": text}}) + "\n\n"
|
||
return _encode_event_frame(sse.encode("utf-8"))
|
||
|
||
|
||
def _make_frames(n: int) -> list[bytes]:
|
||
frames = [_token_frame(f"token{i} ") for i in range(n)]
|
||
assert all(len(f) < 1024 for f in frames)
|
||
return frames
|
||
|
||
|
||
class _CountingSyncStream(httpx.SyncByteStream):
|
||
"""Yields provider frames one at a time and records how many have been pulled."""
|
||
|
||
def __init__(self, frames: list[bytes]) -> None:
|
||
self._frames = frames
|
||
self.consumed = 0
|
||
|
||
def __iter__(self) -> Iterator[bytes]:
|
||
for frame in self._frames:
|
||
self.consumed += 1
|
||
yield frame
|
||
|
||
|
||
class _CountingAsyncStream(httpx.AsyncByteStream):
|
||
"""Yields provider frames one at a time and records how many have been pulled."""
|
||
|
||
def __init__(self, frames: list[bytes]) -> None:
|
||
self._frames = frames
|
||
self.consumed = 0
|
||
|
||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||
for frame in self._frames:
|
||
self.consumed += 1
|
||
yield frame
|
||
|
||
|
||
class _FakeSyncClient:
|
||
def __init__(self, response: httpx.Response) -> None:
|
||
self._response = response
|
||
|
||
def post(self, *args, **kwargs) -> httpx.Response:
|
||
return self._response
|
||
|
||
|
||
class _FakeAsyncClient:
|
||
def __init__(self, response: httpx.Response) -> None:
|
||
self._response = response
|
||
|
||
async def post(self, *args, **kwargs) -> httpx.Response:
|
||
return self._response
|
||
|
||
|
||
def test_sync_native_streaming_forwards_each_frame_incrementally():
|
||
"""Each token must be emitted after exactly one newly-pulled source frame.
|
||
|
||
With the old `chunk_size=1024` the httpx chunker would swallow several small
|
||
frames before yielding, so the first token would arrive only after `consumed`
|
||
had already crossed multiple frames, and tokens would then replay in a burst.
|
||
"""
|
||
frames = _make_frames(24)
|
||
stream = _CountingSyncStream(frames)
|
||
response = httpx.Response(200, stream=stream)
|
||
|
||
completion_stream = SagemakerLLM().make_sync_call(
|
||
api_base="https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/phi-4/invocations-response-stream",
|
||
headers={},
|
||
data="",
|
||
logging_obj=MagicMock(),
|
||
client=_FakeSyncClient(response),
|
||
)
|
||
|
||
consumed_at_token = []
|
||
texts = []
|
||
for chunk in completion_stream:
|
||
if chunk is not None and chunk["text"]:
|
||
consumed_at_token.append(stream.consumed)
|
||
texts.append(chunk["text"])
|
||
|
||
assert texts == [f"token{i} " for i in range(len(frames))]
|
||
assert consumed_at_token == list(range(1, len(frames) + 1))
|
||
|
||
|
||
def test_sync_native_streaming_raises_sagemaker_error_on_non_200():
|
||
response = httpx.Response(500, text="boom")
|
||
|
||
with pytest.raises(SagemakerError) as exc_info:
|
||
SagemakerLLM().make_sync_call(
|
||
api_base="https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/phi-4/invocations-response-stream",
|
||
headers={},
|
||
data="",
|
||
logging_obj=MagicMock(),
|
||
client=_FakeSyncClient(response),
|
||
)
|
||
|
||
assert exc_info.value.status_code == 500
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_native_streaming_forwards_each_frame_incrementally():
|
||
"""Each token must be emitted after exactly one newly-pulled source frame.
|
||
|
||
With the old `chunk_size=1024` the httpx chunker would swallow several small
|
||
frames before yielding, so the first token would arrive only after `consumed`
|
||
had already crossed multiple frames, and tokens would then replay in a burst.
|
||
"""
|
||
frames = _make_frames(24)
|
||
stream = _CountingAsyncStream(frames)
|
||
response = httpx.Response(200, stream=stream)
|
||
|
||
completion_stream = await SagemakerLLM().make_async_call(
|
||
api_base="https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/phi-4/invocations-response-stream",
|
||
headers={},
|
||
data="",
|
||
logging_obj=MagicMock(),
|
||
client=_FakeAsyncClient(response),
|
||
)
|
||
|
||
consumed_at_token = []
|
||
texts = []
|
||
async for chunk in completion_stream:
|
||
if chunk is not None and chunk["text"]:
|
||
consumed_at_token.append(stream.consumed)
|
||
texts.append(chunk["text"])
|
||
|
||
assert texts == [f"token{i} " for i in range(len(frames))]
|
||
assert consumed_at_token == list(range(1, len(frames) + 1))
|
||
|
||
|
||
def test_load_credentials_assumes_role_with_external_id(monkeypatch):
|
||
"""A trust policy requiring sts:ExternalId must be satisfied by the deployment's aws_external_id."""
|
||
import datetime
|
||
|
||
import boto3
|
||
from botocore.exceptions import ClientError
|
||
from unittest.mock import patch
|
||
|
||
monkeypatch.delenv("AWS_EXTERNAL_ID", raising=False)
|
||
|
||
class FakeSTSClient:
|
||
def get_caller_identity(self):
|
||
return {"Arn": "arn:aws:iam::111111111111:user/litellm-proxy-pod"}
|
||
|
||
def assume_role(self, **params):
|
||
if params.get("ExternalId") != "external-id-sm-completion":
|
||
raise ClientError(
|
||
{"Error": {"Code": "AccessDenied", "Message": "is not authorized to perform: sts:AssumeRole"}},
|
||
"AssumeRole",
|
||
)
|
||
return {
|
||
"Credentials": {
|
||
"AccessKeyId": "ASIASMCOMPROLEKEY",
|
||
"SecretAccessKey": "assumed-secret",
|
||
"SessionToken": "assumed-session-token",
|
||
"Expiration": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=30),
|
||
}
|
||
}
|
||
|
||
optional_params = {
|
||
"aws_access_key_id": "AKIASMCOMPCALLERKEY",
|
||
"aws_secret_access_key": "pod-caller-secret",
|
||
"aws_region_name": "us-east-1",
|
||
"aws_role_name": "arn:aws:iam::999999999999:role/litellm-sm-completion-role",
|
||
"aws_session_name": "litellm-sm-completion-session",
|
||
"aws_external_id": "external-id-sm-completion",
|
||
}
|
||
|
||
with patch.object(boto3, "client", return_value=FakeSTSClient()):
|
||
credentials, aws_region_name = SagemakerLLM()._load_credentials(optional_params)
|
||
|
||
assert credentials.access_key == "ASIASMCOMPROLEKEY"
|
||
assert credentials.token == "assumed-session-token"
|
||
assert aws_region_name == "us-east-1"
|
||
assert "aws_external_id" not in optional_params
|
||
|
||
|
||
def test_load_credentials_assumes_role_with_session_tags(monkeypatch):
|
||
"""A trust policy gated on sts:TagSession only admits the session when the deployment's tags are sent."""
|
||
import datetime
|
||
|
||
import boto3
|
||
from botocore.exceptions import ClientError
|
||
from unittest.mock import patch
|
||
|
||
monkeypatch.delenv("AWS_WEB_IDENTITY_TOKEN_FILE", raising=False)
|
||
monkeypatch.delenv("AWS_ROLE_ARN", raising=False)
|
||
tags = [{"Key": "team", "Value": "genai"}]
|
||
|
||
class FakeSTSClient:
|
||
def get_caller_identity(self):
|
||
return {"Arn": "arn:aws:iam::111111111111:user/litellm-proxy-pod"}
|
||
|
||
def assume_role(self, **params):
|
||
if list(params.get("Tags", ())) != tags:
|
||
raise ClientError(
|
||
{"Error": {"Code": "AccessDenied", "Message": "is not authorized to perform: sts:TagSession"}},
|
||
"AssumeRole",
|
||
)
|
||
return {
|
||
"Credentials": {
|
||
"AccessKeyId": "ASIASMCOMPTAGGED",
|
||
"SecretAccessKey": "assumed-secret",
|
||
"SessionToken": "assumed-session-token",
|
||
"Expiration": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=30),
|
||
}
|
||
}
|
||
|
||
optional_params = {
|
||
"aws_access_key_id": "AKIASMCOMPCALLERKEY",
|
||
"aws_secret_access_key": "pod-caller-secret",
|
||
"aws_region_name": "us-east-1",
|
||
"aws_role_name": "arn:aws:iam::999999999999:role/litellm-sm-completion-role",
|
||
"aws_session_name": "litellm-sm-completion-session",
|
||
"aws_session_tags": tags,
|
||
}
|
||
|
||
with patch.object(boto3, "client", return_value=FakeSTSClient()):
|
||
credentials, aws_region_name = SagemakerLLM()._load_credentials(optional_params)
|
||
|
||
assert credentials.access_key == "ASIASMCOMPTAGGED"
|
||
assert aws_region_name == "us-east-1"
|
||
assert "aws_session_tags" not in optional_params
|
||
|
||
|
||
def test_missing_botocore_keeps_dependency_identity():
|
||
from unittest.mock import patch
|
||
|
||
import pytest
|
||
|
||
from litellm.llms.sagemaker.completion.handler import SagemakerLLM
|
||
|
||
with patch.dict("sys.modules", {"botocore": None}):
|
||
with pytest.raises(ModuleNotFoundError, match="pip install boto3") as caught:
|
||
SagemakerLLM()._load_credentials({})
|
||
assert caught.value.name == "botocore"
|
||
|
||
|
||
def test_installed_botocore_signs_the_completion_request():
|
||
from botocore.credentials import Credentials
|
||
|
||
from litellm.llms.sagemaker.completion.handler import SagemakerLLM
|
||
|
||
request = SagemakerLLM()._prepare_request(
|
||
credentials=Credentials("test-key", "test-secret"), model="test-endpoint", data={"inputs": "ping"},
|
||
messages=[], litellm_params={}, optional_params={}, aws_region_name="us-west-2",
|
||
)
|
||
assert request.body == b'{"inputs": "ping"}'
|
||
assert "/us-west-2/sagemaker/aws4_request" in request.headers["Authorization"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.usefixtures("httpx_transport")
|
||
@pytest.mark.usefixtures("fake_provider_credentials")
|
||
async def test_acompletion_sagemaker_non_stream(
|
||
respx_mock: respx.MockRouter,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
monkeypatch.delenv("AWS_REGION_NAME", raising=False)
|
||
monkeypatch.delenv("AWS_REGION", raising=False)
|
||
url: Final = (
|
||
"https://runtime.sagemaker.us-west-2.amazonaws.com/endpoints/"
|
||
"jumpstart-dft-hf-textgeneration1-mp-20240815-185614/invocations"
|
||
)
|
||
route: Final = respx_mock.post(url).mock(
|
||
return_value=httpx.Response(200, json={"generated_text": "SageMaker reply"})
|
||
)
|
||
response: Final = await litellm.acompletion(
|
||
model="sagemaker/jumpstart-dft-hf-textgeneration1-mp-20240815-185614",
|
||
messages=[{"role": "user", "content": "hi"}],
|
||
temperature=0.2,
|
||
max_tokens=80,
|
||
cost_per_second=0.000420,
|
||
num_retries=0,
|
||
)
|
||
|
||
assert route.called
|
||
assert str(route.calls[0].request.url) == url
|
||
assert json.loads(route.calls[0].request.content) == {
|
||
"inputs": "hi",
|
||
"parameters": {"temperature": 0.2, "max_new_tokens": 80},
|
||
}
|
||
assert response.choices[0].message.content == "SageMaker reply"
|
||
|
||
|
||
@pytest.mark.usefixtures("httpx_transport")
|
||
@pytest.mark.usefixtures("fake_provider_credentials")
|
||
def test_completion_sagemaker_non_stream(
|
||
respx_mock: respx.MockRouter,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
monkeypatch.delenv("AWS_REGION_NAME", raising=False)
|
||
monkeypatch.delenv("AWS_REGION", raising=False)
|
||
url: Final = (
|
||
"https://runtime.sagemaker.us-west-2.amazonaws.com/endpoints/"
|
||
"jumpstart-dft-hf-textgeneration1-mp-20240815-185614/invocations"
|
||
)
|
||
route: Final = respx_mock.post(url).mock(
|
||
return_value=httpx.Response(200, json={"generated_text": "SageMaker reply"})
|
||
)
|
||
response: Final = litellm.completion(
|
||
model="sagemaker/jumpstart-dft-hf-textgeneration1-mp-20240815-185614",
|
||
messages=[{"role": "user", "content": "hi"}],
|
||
temperature=0.2,
|
||
max_tokens=80,
|
||
cost_per_second=0.000420,
|
||
num_retries=0,
|
||
)
|
||
|
||
assert route.called
|
||
assert str(route.calls[0].request.url) == url
|
||
assert json.loads(route.calls[0].request.content) == {
|
||
"inputs": "hi",
|
||
"parameters": {"temperature": 0.2, "max_new_tokens": 80},
|
||
}
|
||
assert response.choices[0].message.content == "SageMaker reply"
|
||
|
||
|
||
@pytest.mark.usefixtures("httpx_transport")
|
||
@pytest.mark.usefixtures("fake_provider_credentials")
|
||
def test_completion_sagemaker_non_stream_with_aws_params(respx_mock: respx.MockRouter) -> None:
|
||
url: Final = (
|
||
"https://runtime.sagemaker.us-west-5.amazonaws.com/endpoints/"
|
||
"jumpstart-dft-hf-textgeneration1-mp-20240815-185614/invocations"
|
||
)
|
||
route: Final = respx_mock.post(url).mock(
|
||
return_value=httpx.Response(200, json={"generated_text": "SageMaker reply"})
|
||
)
|
||
response: Final = litellm.completion(
|
||
model="sagemaker/jumpstart-dft-hf-textgeneration1-mp-20240815-185614",
|
||
messages=[{"role": "user", "content": "hi"}],
|
||
temperature=0.2,
|
||
max_tokens=80,
|
||
cost_per_second=0.000420,
|
||
aws_access_key_id="unit-test-access-key",
|
||
aws_secret_access_key="unit-test-secret",
|
||
aws_region_name="us-west-5",
|
||
num_retries=0,
|
||
)
|
||
|
||
assert route.called
|
||
assert str(route.calls[0].request.url) == url
|
||
assert json.loads(route.calls[0].request.content) == {
|
||
"inputs": "hi",
|
||
"parameters": {"temperature": 0.2, "max_new_tokens": 80},
|
||
}
|
||
assert response.choices[0].message.content == "SageMaker reply"
|
||
|
||
|
||
@pytest.mark.usefixtures("httpx_transport")
|
||
@pytest.mark.usefixtures("fake_provider_credentials")
|
||
def test_completion_sagemaker_prompt_template_non_stream(
|
||
respx_mock: respx.MockRouter,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
monkeypatch.setattr(litellm, "known_tokenizer_config", {})
|
||
tokenizer_route: Final = respx_mock.get(
|
||
"https://huggingface.co/deepseek-ai/deepseek-coder-6.7b-instruct/raw/main/tokenizer_config.json"
|
||
).mock(
|
||
return_value=httpx.Response(
|
||
200,
|
||
json={
|
||
"bos_token": {"content": "<|begin▁of▁sentence|>"},
|
||
"eos_token": {"content": "<|EOT|>"},
|
||
"chat_template": (
|
||
"{{ bos_token }}You are a coding assistant\n"
|
||
"{% for message in messages %}### Instruction:\n{{ message['content'] }}\n{% endfor %}"
|
||
"### Response:"
|
||
),
|
||
},
|
||
)
|
||
)
|
||
url: Final = (
|
||
"https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/deepseek_coder_6.7_instruct/invocations"
|
||
)
|
||
route: Final = respx_mock.post(url).mock(
|
||
return_value=httpx.Response(200, json={"generated_text": "SageMaker reply"})
|
||
)
|
||
response: Final = litellm.completion(
|
||
model="sagemaker/deepseek_coder_6.7_instruct",
|
||
messages=[{"role": "user", "content": "hi"}],
|
||
temperature=0.2,
|
||
max_tokens=80,
|
||
hf_model_name="deepseek-ai/deepseek-coder-6.7b-instruct",
|
||
aws_region_name="us-east-1",
|
||
num_retries=0,
|
||
)
|
||
|
||
assert tokenizer_route.call_count == 1
|
||
assert json.loads(route.calls[0].request.content) == {
|
||
"inputs": "<|begin▁of▁sentence|>You are a coding assistant\n### Instruction:\nhi\n### Response:",
|
||
"parameters": {"temperature": 0.2, "max_new_tokens": 80},
|
||
}
|
||
assert response.choices[0].message.content == "SageMaker reply"
|
||
|
||
|
||
@pytest.mark.usefixtures("httpx_transport")
|
||
@pytest.mark.usefixtures("fake_provider_credentials")
|
||
@pytest.mark.parametrize(
|
||
("environment", "call_kwargs", "expected_region"),
|
||
[
|
||
({}, {}, "us-west-2"),
|
||
({"AWS_REGION_NAME": "us-east-1"}, {}, "us-east-1"),
|
||
({"AWS_REGION_NAME": "eu-west-1"}, {"aws_region_name": "us-east-1"}, "us-east-1"),
|
||
],
|
||
ids=["default_region", "environment_region", "config_region_beats_environment"],
|
||
)
|
||
def test_sagemaker_region_resolution(
|
||
respx_mock: respx.MockRouter,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
environment: dict[str, str],
|
||
call_kwargs: dict[str, str],
|
||
expected_region: str,
|
||
) -> None:
|
||
monkeypatch.delenv("AWS_REGION_NAME", raising=False)
|
||
monkeypatch.delenv("AWS_REGION", raising=False)
|
||
for name, value in environment.items():
|
||
monkeypatch.setenv(name, value)
|
||
url: Final = f"https://runtime.sagemaker.{expected_region}.amazonaws.com/endpoints/mock-endpoint/invocations"
|
||
route: Final = respx_mock.post(url).mock(
|
||
return_value=httpx.Response(200, json={"generated_text": "SageMaker reply"})
|
||
)
|
||
litellm.completion(
|
||
model="sagemaker/mock-endpoint",
|
||
messages=[{"role": "user", "content": "Hello, world!"}],
|
||
num_retries=0,
|
||
**call_kwargs,
|
||
)
|
||
|
||
assert route.call_count == 1
|
||
assert str(route.calls[0].request.url) == url
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.usefixtures("httpx_transport")
|
||
@pytest.mark.usefixtures("fake_provider_credentials")
|
||
@pytest.mark.parametrize("sync_mode", [True, False])
|
||
async def test_completion_sagemaker(
|
||
respx_mock: respx.MockRouter,
|
||
sync_mode: bool,
|
||
) -> None:
|
||
url: Final = (
|
||
"https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/"
|
||
"jumpstart-dft-hf-textgeneration1-mp-20240815-185614/invocations"
|
||
)
|
||
route: Final = respx_mock.post(url).mock(
|
||
return_value=httpx.Response(200, json={"generated_text": "SageMaker reply"})
|
||
)
|
||
rate: Final = 0.000420
|
||
response: Final = (
|
||
litellm.completion(
|
||
model="sagemaker/jumpstart-dft-hf-textgeneration1-mp-20240815-185614",
|
||
messages=[{"role": "user", "content": "hi"}],
|
||
temperature=0.2,
|
||
max_tokens=80,
|
||
cost_per_second=rate,
|
||
aws_region_name="us-east-1",
|
||
num_retries=0,
|
||
)
|
||
if sync_mode
|
||
else await litellm.acompletion(
|
||
model="sagemaker/jumpstart-dft-hf-textgeneration1-mp-20240815-185614",
|
||
messages=[{"role": "user", "content": "hi"}],
|
||
temperature=0.2,
|
||
max_tokens=80,
|
||
cost_per_second=rate,
|
||
aws_region_name="us-east-1",
|
||
num_retries=0,
|
||
)
|
||
)
|
||
response._response_ms = 1000.0
|
||
|
||
assert route.called
|
||
assert response.choices[0].message.content == "SageMaker reply"
|
||
assert litellm.completion_cost(completion_response=response) == pytest.approx(rate)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.usefixtures("httpx_transport")
|
||
@pytest.mark.usefixtures("fake_provider_credentials")
|
||
@pytest.mark.parametrize("sync_mode", [True, False])
|
||
async def test_completion_sagemaker_stream(
|
||
respx_mock: respx.MockRouter,
|
||
sync_mode: bool,
|
||
) -> None:
|
||
url: Final = (
|
||
"https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/"
|
||
"jumpstart-dft-hf-textgeneration1-mp-20240815-185614/invocations-response-stream"
|
||
)
|
||
route: Final = respx_mock.post(url).mock(
|
||
return_value=httpx.Response(200, content=_token_frame("hello") + _token_frame(" world"))
|
||
)
|
||
stream: Final = (
|
||
litellm.completion(
|
||
model="sagemaker/jumpstart-dft-hf-textgeneration1-mp-20240815-185614",
|
||
messages=[{"role": "user", "content": "hi - what is ur name"}],
|
||
stream=True,
|
||
temperature=0.2,
|
||
max_tokens=80,
|
||
cost_per_second=0.000420,
|
||
aws_region_name="us-east-1",
|
||
num_retries=0,
|
||
)
|
||
if sync_mode
|
||
else await litellm.acompletion(
|
||
model="sagemaker/jumpstart-dft-hf-textgeneration1-mp-20240815-185614",
|
||
messages=[{"role": "user", "content": "hi - what is ur name"}],
|
||
stream=True,
|
||
temperature=0.2,
|
||
max_tokens=80,
|
||
cost_per_second=0.000420,
|
||
aws_region_name="us-east-1",
|
||
num_retries=0,
|
||
)
|
||
)
|
||
chunks: Final = tuple(stream) if sync_mode else tuple([chunk async for chunk in stream])
|
||
|
||
assert json.loads(route.calls[0].request.content) == {
|
||
"inputs": "hi - what is ur name",
|
||
"parameters": {"temperature": 0.2, "max_new_tokens": 80},
|
||
"stream": True,
|
||
}
|
||
assert chunks[0].choices[0].delta.role == "assistant"
|
||
assert tuple(chunk.choices[0].delta.content for chunk in chunks) == ("hello", " world", None)
|
||
assert tuple(chunk.choices[0].finish_reason for chunk in chunks) == (None, None, "stop")
|
||
|
||
|
||
@pytest.mark.usefixtures("httpx_transport")
|
||
@pytest.mark.usefixtures("fake_provider_credentials")
|
||
@pytest.mark.parametrize("sync_mode", [True, False])
|
||
@pytest.mark.asyncio
|
||
async def test_completion_sagemaker_streaming_bad_request(
|
||
respx_mock: respx.MockRouter,
|
||
sync_mode: bool,
|
||
) -> None:
|
||
url: Final = (
|
||
"https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/"
|
||
"jumpstart-dft-hf-textgeneration1-mp-20240815-185614/invocations-response-stream"
|
||
)
|
||
route: Final = respx_mock.post(url).mock(
|
||
return_value=httpx.Response(
|
||
400,
|
||
json={"Message": "The request payload failed validation.", "__type": "ValidationException"},
|
||
)
|
||
)
|
||
|
||
with pytest.raises(litellm.BadRequestError) as exc_info:
|
||
litellm.completion(
|
||
model="sagemaker/jumpstart-dft-hf-textgeneration1-mp-20240815-185614",
|
||
messages=[{"role": "user", "content": "hi"}],
|
||
stream=True,
|
||
aws_region_name="us-east-1",
|
||
num_retries=0,
|
||
) if sync_mode else await litellm.acompletion(
|
||
model="sagemaker/jumpstart-dft-hf-textgeneration1-mp-20240815-185614",
|
||
messages=[{"role": "user", "content": "hi"}],
|
||
stream=True,
|
||
aws_region_name="us-east-1",
|
||
num_retries=0,
|
||
)
|
||
|
||
assert route.called
|
||
assert exc_info.value.status_code == 400
|
||
assert exc_info.value.llm_provider == "sagemaker"
|