mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-03 07:23:33 +00:00
* feat(sagemaker): inference components and OpenAI-compatible embeddings
Send model_id on a SageMaker embedding deployment as the invoke call's
InferenceComponentName, sync and async, and keep it out of the request body.
Add the sagemaker/openai/<endpoint> route, which sends the OpenAI embeddings
request shape ({"input": [...]} plus extra_body fields) and reads
data[].embedding and usage from the response for containers that serve the
OpenAI embeddings API. Plain sagemaker/<endpoint> deployments keep the
Hugging Face shape
* refactor(sagemaker): type validate_environment messages and cover base64 embeddings
* fix(sagemaker): name model_id in inference component errors and reject a non-string model_id on embeddings
* fix(sagemaker): fall back to the error text when SageMaker sends a null message
A ClientError whose Error.Message is null (SageMaker's InternalFailure shape) reached the
inference component hint as None and crashed with a TypeError; the message now falls back
to the error's own text on the chat and embedding paths
* test(sagemaker): audit matrix for inference-component embeddings
* fix(sagemaker): read the error status and text through one shape-checked helper
botocore's ConnectionClosedError carries response=None, so the except blocks that
read e.response.get(...) crashed with an AttributeError and the proxy answered 400
"Invalid request format" for a dropped connection. client_error_details validates
the botocore error shape with pydantic and falls back to 500 plus the error's own
text for any other shape (a missing response, an httpx Response, a plain exception).
* fix(sagemaker): drop the inference component hint from chat and derive the SDK wire expectation from the request
The chat except blocks never see SageMaker's text: HTTPHandler.post raises from
raise_for_status before the handler's own SagemakerError line, so the hint check ran
on httpx's generic "Client error '400 Bad Request' for url" line and could not match on
any chat path. The hint now names litellm.embedding only and the chat blocks keep the
status and text they had, without the dead check. The two OpenAI SDK wire tests pinned
the SDK's default encoding_format; they now expect whatever the SDK sent.
* refactor(sagemaker): meet main's per-rule lint and type ceilings
Main started gating every lint rule on its merge-base count and added LIT015
(frozen pydantic models) after this branch was cut, so the merge with main
pushed LIT001, LIT011, LIT015, BLE001 and reportArgumentType past their
ceilings. The response models are frozen with tuple fields, the OpenAI
embedding response is built with model_copy instead of writing into the
caller's object, the invoke_endpoint arguments are a TypedDict, and
optional_params and extra_body are validated into Mapping[str, object] before
they are read
* refactor(sagemaker): pick the embedding config in one place and stop pinning upstream error text
The openai/, Voyage and Cohere checks lived in both get_model_config and
get_supported_openai_params, so they now share one lookup. Tests compare error
messages against the error's own text, or against botocore's and json's own
wording, instead of copying that wording, and assert whole messages where the
text is ours
* fix(caching): key direct calls on model_id so inference components don't share cached embeddings
* test(sagemaker): name the fake-boto3 SDK test class for what it covers
* test(sagemaker): kill and stop chaos workers through the owned-proxy harness
---------
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
171 lines
7.1 KiB
Python
171 lines
7.1 KiB
Python
import uuid
|
|
from collections.abc import Iterator
|
|
from typing import Final
|
|
|
|
import pytest
|
|
from integration._support.sagemaker_embedding import (
|
|
ACCESS_KEY,
|
|
COMPONENT,
|
|
COMPONENT_HEADER,
|
|
JSON_OBJECT,
|
|
NON_STRING_MODEL_ID,
|
|
REGION,
|
|
SECRET_KEY,
|
|
SERVED_MODEL,
|
|
endpoint,
|
|
invocations_target,
|
|
litellm_params,
|
|
openai_route,
|
|
plain_route,
|
|
respond,
|
|
)
|
|
from integration._support.wire import Request, Wire, wire_server
|
|
from pydantic import JsonValue
|
|
|
|
import litellm
|
|
|
|
_AWS: Final[dict[str, str]] = {
|
|
"aws_access_key_id": ACCESS_KEY,
|
|
"aws_secret_access_key": SECRET_KEY,
|
|
"aws_region_name": REGION,
|
|
}
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def wire() -> Iterator[Wire]:
|
|
with wire_server(respond) as served:
|
|
yield served
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _runtime_at_the_peer(wire: Wire, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
wire.drain()
|
|
monkeypatch.setenv("AWS_ENDPOINT_URL_SAGEMAKER_RUNTIME", wire.url)
|
|
monkeypatch.setenv("AWS_MAX_ATTEMPTS", "1")
|
|
|
|
|
|
def _text() -> str:
|
|
return f"integration embedding {uuid.uuid4().hex}"
|
|
|
|
|
|
def _only_request(wire: Wire) -> Request:
|
|
received: Final = wire.drain()
|
|
assert len(received) == 1, [(request.method, request.target) for request in received]
|
|
return received[0]
|
|
|
|
|
|
def _signed_headers(request: Request) -> tuple[str, ...]:
|
|
authorization: Final = request.headers["authorization"]
|
|
assert authorization.startswith(f"AWS4-HMAC-SHA256 Credential={ACCESS_KEY}/"), authorization
|
|
signed: Final = next(part for part in authorization.split(", ") if part.startswith("SignedHeaders="))
|
|
return tuple(signed.removeprefix("SignedHeaders=").split(";"))
|
|
|
|
|
|
def _assert_component_request(request: Request, scenario: str, body: dict[str, JsonValue]) -> None:
|
|
assert (request.method, request.target) == ("POST", invocations_target(endpoint(scenario))), request
|
|
assert request.headers[COMPONENT_HEADER] == COMPONENT, dict(request.headers)
|
|
assert COMPONENT_HEADER in _signed_headers(request), request.headers["authorization"]
|
|
assert JSON_OBJECT.validate_json(request.body) == body, request.body
|
|
|
|
|
|
def _vectors(response: litellm.EmbeddingResponse) -> list[JsonValue]:
|
|
return [row["embedding"] for row in response.data]
|
|
|
|
|
|
def test_embedding_signs_model_id_as_the_inference_component_and_reads_the_served_model(wire: Wire) -> None:
|
|
text: Final = _text()
|
|
response: Final = litellm.embedding(
|
|
model=openai_route("openai"), input=[text], model_id=COMPONENT, extra_body={"model": SERVED_MODEL}, **_AWS
|
|
)
|
|
assert response.model == SERVED_MODEL, response
|
|
assert _vectors(response) == [[0.25, -0.5, 1.0]], response
|
|
assert (response.usage.prompt_tokens, response.usage.total_tokens) == (7, 7), response.usage
|
|
_assert_component_request(_only_request(wire), "openai", {"input": [text], "model": SERVED_MODEL})
|
|
|
|
|
|
async def test_aembedding_signs_model_id_as_the_inference_component_and_reads_the_served_model(wire: Wire) -> None:
|
|
texts: Final = [_text(), _text()]
|
|
response: Final = await litellm.aembedding(
|
|
model=openai_route("openai"), input=texts, model_id=COMPONENT, extra_body={"model": SERVED_MODEL}, **_AWS
|
|
)
|
|
assert response.model == SERVED_MODEL, response
|
|
assert _vectors(response) == [[0.25, -0.5, 1.0], [0.25, -0.5, 2.0]], response
|
|
_assert_component_request(_only_request(wire), "openai", {"input": texts, "model": SERVED_MODEL})
|
|
|
|
|
|
def test_embedding_names_the_endpoint_when_the_container_reports_no_model(wire: Wire) -> None:
|
|
text: Final = _text()
|
|
response: Final = litellm.embedding(model=openai_route("nomodel"), input=[text], model_id=COMPONENT, **_AWS)
|
|
assert response.model == endpoint("nomodel"), response
|
|
assert _vectors(response) == [[0.25, -0.5, 1.0]], response
|
|
_assert_component_request(_only_request(wire), "nomodel", {"input": [text]})
|
|
|
|
|
|
def test_plain_route_keeps_the_hf_body_and_signs_the_component_header(wire: Wire) -> None:
|
|
text: Final = _text()
|
|
response: Final = litellm.embedding(model=plain_route("hf"), input=[text], model_id=COMPONENT, **_AWS)
|
|
assert response.model == endpoint("hf"), response
|
|
assert _vectors(response) == [[0.25, -0.5, 1.0]], response
|
|
assert response.usage.prompt_tokens == 3, response.usage
|
|
_assert_component_request(_only_request(wire), "hf", {"inputs": [text]})
|
|
|
|
|
|
def test_plain_route_without_model_id_sends_no_component_header(wire: Wire) -> None:
|
|
text: Final = _text()
|
|
response: Final = litellm.embedding(model=plain_route("hf"), input=[text], **_AWS)
|
|
assert _vectors(response) == [[0.25, -0.5, 1.0]], response
|
|
request: Final = _only_request(wire)
|
|
assert (request.method, request.target) == ("POST", invocations_target(endpoint("hf"))), request
|
|
assert COMPONENT_HEADER not in request.headers, dict(request.headers)
|
|
assert COMPONENT_HEADER not in _signed_headers(request), request.headers["authorization"]
|
|
assert JSON_OBJECT.validate_json(request.body) == {"inputs": [text]}, request.body
|
|
|
|
|
|
def test_explicit_provider_spelling_signs_model_id_as_the_inference_component(wire: Wire) -> None:
|
|
text: Final = _text()
|
|
response: Final = litellm.embedding(
|
|
model=f"openai/{endpoint('openai')}",
|
|
custom_llm_provider="sagemaker",
|
|
input=[text],
|
|
model_id=COMPONENT,
|
|
extra_body={"model": SERVED_MODEL},
|
|
**_AWS,
|
|
)
|
|
assert response.model == SERVED_MODEL, response
|
|
assert _vectors(response) == [[0.25, -0.5, 1.0]], response
|
|
_assert_component_request(_only_request(wire), "openai", {"input": [text], "model": SERVED_MODEL})
|
|
|
|
|
|
async def test_router_aembedding_forwards_the_deployment_model_id_as_the_component(wire: Wire) -> None:
|
|
text: Final = _text()
|
|
router: Final = litellm.Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "sm-openai-router",
|
|
"litellm_params": litellm_params(
|
|
openai_route("openai"), model_id=COMPONENT, extra_body={"model": SERVED_MODEL}
|
|
),
|
|
}
|
|
],
|
|
num_retries=0,
|
|
)
|
|
response: Final = await router.aembedding(model="sm-openai-router", input=[text])
|
|
assert response.model == SERVED_MODEL, response
|
|
assert _vectors(response) == [[0.25, -0.5, 1.0]], response
|
|
_assert_component_request(_only_request(wire), "openai", {"input": [text], "model": SERVED_MODEL})
|
|
|
|
|
|
def test_embedding_rejects_a_non_string_model_id_before_calling_aws(wire: Wire) -> None:
|
|
with pytest.raises(litellm.BadRequestError) as caught:
|
|
litellm.embedding(model=openai_route("openai"), input=[_text()], model_id=[COMPONENT], **_AWS)
|
|
assert caught.value.status_code == 400, caught.value
|
|
assert NON_STRING_MODEL_ID in str(caught.value), caught.value
|
|
assert wire.drain() == (), "AWS was called with a list-valued model_id"
|
|
|
|
|
|
def test_embedding_surfaces_the_container_model_error_with_its_status(wire: Wire) -> None:
|
|
with pytest.raises(litellm.BadRequestError) as caught:
|
|
litellm.embedding(model=openai_route("err424"), input=[_text()], model_id=COMPONENT, **_AWS)
|
|
assert caught.value.status_code == 424, caught.value
|
|
assert "unexpected field inputs" in str(caught.value), caught.value
|
|
assert _only_request(wire).headers[COMPONENT_HEADER] == COMPONENT
|