mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
fix(http): address review on outbound HTTP/2
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
a426df108a
commit
fd2fb4c44e
7 changed files with 269 additions and 254 deletions
|
|
@ -524,7 +524,7 @@ aiohttp_trust_env: bool = False # set to true to use HTTP_ Proxy settings
|
|||
disable_aiohttp_transport: bool = False # Set this to true to use httpx instead
|
||||
disable_aiohttp_trust_env: bool = False # When False, aiohttp will respect HTTP(S)_PROXY env vars
|
||||
force_ipv4: bool = False # when True, litellm will force ipv4 for all LLM requests. Some users have seen httpx ConnectionError when using ipv6.
|
||||
http2: bool = False # when True, LiteLLM-built httpx clients negotiate HTTP/2 over TLS (falls back to HTTP/1.1); bypasses aiohttp transport
|
||||
http2: bool = False
|
||||
network_mock: bool = False # When True, use mock transport — no real network calls
|
||||
|
||||
####### STOP SEQUENCE LIMIT #######
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ from litellm.llms.base_llm.base_model_iterator import (
|
|||
)
|
||||
from litellm.llms.bedrock.common_utils import BedrockModelInfo
|
||||
from litellm.llms.cohere.common_utils import CohereModelInfo
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler, http2_enabled
|
||||
from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config
|
||||
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
|
||||
from litellm.llms.vertex_ai.common_utils import (
|
||||
|
|
@ -2341,6 +2341,10 @@ def _complete_sap(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
|
|||
def _complete_aiohttp_openai(
|
||||
ctx: _CompletionDispatchContext,
|
||||
) -> _CompletionDispatchResult:
|
||||
if http2_enabled():
|
||||
verbose_logger.warning(
|
||||
"litellm.http2 is enabled but aiohttp_openai/ always uses aiohttp, which has no HTTP/2 client; this request stays on HTTP/1.1"
|
||||
)
|
||||
acompletion: Final = ctx.acompletion
|
||||
api_base = ctx.api_base
|
||||
api_key = ctx.api_key
|
||||
|
|
|
|||
208
tests/e2e/llm_translation/test_outbound_http2_e2e.py
Normal file
208
tests/e2e/llm_translation/test_outbound_http2_e2e.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"""Outbound HTTP/2 negotiation for LiteLLM-built httpx clients.
|
||||
|
||||
Spins up a local hypercorn TLS server that offers h2 and http/1.1 over ALPN and
|
||||
drives the real AsyncHTTPHandler / HTTPHandler at it, so the negotiated protocol
|
||||
on the wire is the assertion. No running proxy or provider credentials needed,
|
||||
which is why these tests carry no `e2e` marker (same shape as the markerless
|
||||
harness checks under tests/e2e/load/).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import ipaddress
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Final, cast
|
||||
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
from hypercorn.asyncio import (
|
||||
serve, # pyright: ignore[reportUnknownVariableType] # hypercorn's serve signature passes through untyped worker hooks
|
||||
)
|
||||
from hypercorn.config import Config
|
||||
from hypercorn.typing import (
|
||||
ASGIReceiveCallable,
|
||||
ASGISendCallable,
|
||||
HTTPResponseBodyEvent,
|
||||
HTTPResponseStartEvent,
|
||||
Scope,
|
||||
)
|
||||
|
||||
import litellm
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
|
||||
|
||||
def _write_self_signed_cert(cert_dir: Path) -> tuple[Path, Path]:
|
||||
key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
now: Final = datetime.datetime.now(datetime.timezone.utc)
|
||||
name: Final = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
|
||||
cert: Final = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(name)
|
||||
.issuer_name(name)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - datetime.timedelta(days=1))
|
||||
.not_valid_after(now + datetime.timedelta(days=7))
|
||||
.add_extension(
|
||||
x509.SubjectAlternativeName([x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]),
|
||||
critical=False,
|
||||
)
|
||||
.sign(key, hashes.SHA256())
|
||||
)
|
||||
cert_file: Final = cert_dir / "cert.pem"
|
||||
key_file: Final = cert_dir / "key.pem"
|
||||
cert_file.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||
key_file.write_bytes(
|
||||
key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
return cert_file, key_file
|
||||
|
||||
|
||||
async def _asgi_app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None:
|
||||
if scope["type"] != "http":
|
||||
return
|
||||
while True:
|
||||
message = await receive()
|
||||
if message["type"] == "http.disconnect":
|
||||
return
|
||||
if message["type"] == "http.request" and not message["more_body"]:
|
||||
break
|
||||
if scope["path"] == "/stream":
|
||||
await send(
|
||||
HTTPResponseStartEvent(
|
||||
type="http.response.start", status=200, headers=[(b"content-type", b"text/event-stream")]
|
||||
)
|
||||
)
|
||||
for index in range(3):
|
||||
await send(
|
||||
HTTPResponseBodyEvent(
|
||||
type="http.response.body", body=f"data: chunk-{index}\n\n".encode(), more_body=True
|
||||
)
|
||||
)
|
||||
await send(HTTPResponseBodyEvent(type="http.response.body", body=b"", more_body=False))
|
||||
return
|
||||
await send(
|
||||
HTTPResponseStartEvent(type="http.response.start", status=200, headers=[(b"content-type", b"application/json")])
|
||||
)
|
||||
await send(HTTPResponseBodyEvent(type="http.response.body", body=b'{"ok": true}', more_body=False))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def http2_tls_server(tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]:
|
||||
cert_dir: Final = tmp_path_factory.mktemp("h2certs")
|
||||
cert_file, key_file = _write_self_signed_cert(cert_dir)
|
||||
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port: Final = cast(int, sock.getsockname()[1])
|
||||
|
||||
shutdown: Final = threading.Event()
|
||||
|
||||
def _serve() -> None:
|
||||
loop: Final = asyncio.new_event_loop()
|
||||
config: Final = Config()
|
||||
config.bind = [f"127.0.0.1:{port}"]
|
||||
config.certfile = str(cert_file)
|
||||
config.keyfile = str(key_file)
|
||||
config.alpn_protocols = ["h2", "http/1.1"]
|
||||
loop.run_until_complete(serve(_asgi_app, config, shutdown_trigger=lambda: asyncio.to_thread(shutdown.wait)))
|
||||
loop.close()
|
||||
|
||||
thread: Final = threading.Thread(target=_serve, daemon=True)
|
||||
thread.start()
|
||||
|
||||
for _ in range(100):
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
pytest.fail("hypercorn test server did not start")
|
||||
|
||||
yield f"https://127.0.0.1:{port}"
|
||||
|
||||
shutdown.set()
|
||||
thread.join(timeout=10)
|
||||
|
||||
|
||||
def _async_exchange(base_url: str) -> tuple[str, str, bytes]:
|
||||
async def _run() -> tuple[str, str, bytes]:
|
||||
handler: Final = AsyncHTTPHandler(ssl_verify=False)
|
||||
try:
|
||||
response: Final = await handler.client.post(f"{base_url}/echo", json={"ping": "pong"})
|
||||
post_version: Final = response.http_version
|
||||
async with handler.client.stream("POST", f"{base_url}/stream", json={}) as stream_response:
|
||||
stream_version: Final = stream_response.http_version
|
||||
body: Final = b"".join([chunk async for chunk in stream_response.aiter_bytes()])
|
||||
return post_version, stream_version, body
|
||||
finally:
|
||||
await handler.close()
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
def _sync_exchange(base_url: str) -> tuple[str, str, bytes]:
|
||||
handler: Final = HTTPHandler(ssl_verify=False)
|
||||
try:
|
||||
response: Final = handler.client.post(f"{base_url}/echo", json={"ping": "pong"})
|
||||
post_version: Final = response.http_version
|
||||
with handler.client.stream("POST", f"{base_url}/stream", json={}) as stream_response:
|
||||
stream_version: Final = stream_response.http_version
|
||||
body: Final = b"".join(stream_response.iter_bytes())
|
||||
return post_version, stream_version, body
|
||||
finally:
|
||||
handler.close()
|
||||
|
||||
|
||||
class TestOutboundHttp2:
|
||||
@pytest.mark.parametrize("use_http2, expected_version", [(True, "HTTP/2"), (False, "HTTP/1.1")])
|
||||
def test_async_handler_negotiates_http2_only_when_enabled(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
http2_tls_server: str,
|
||||
use_http2: bool,
|
||||
expected_version: str,
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "http2", use_http2)
|
||||
monkeypatch.delenv("LITELLM_HTTP2", raising=False)
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "force_ipv4", False)
|
||||
|
||||
post_version, stream_version, body = _async_exchange(http2_tls_server)
|
||||
|
||||
assert post_version == expected_version
|
||||
assert stream_version == expected_version
|
||||
assert b"data: chunk-0" in body
|
||||
|
||||
@pytest.mark.parametrize("use_http2, expected_version", [(True, "HTTP/2"), (False, "HTTP/1.1")])
|
||||
def test_sync_handler_negotiates_http2_only_when_enabled(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
http2_tls_server: str,
|
||||
use_http2: bool,
|
||||
expected_version: str,
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "http2", use_http2)
|
||||
monkeypatch.delenv("LITELLM_HTTP2", raising=False)
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "force_ipv4", False)
|
||||
|
||||
post_version, stream_version, body = _sync_exchange(http2_tls_server)
|
||||
|
||||
assert post_version == expected_version
|
||||
assert stream_version == expected_version
|
||||
assert b"data: chunk-0" in body
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
import asyncio
|
||||
import datetime
|
||||
import ipaddress
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _write_self_signed_cert(cert_dir):
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "localhost")]))
|
||||
.issuer_name(x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "localhost")]))
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - datetime.timedelta(days=1))
|
||||
.not_valid_after(now + datetime.timedelta(days=7))
|
||||
.add_extension(
|
||||
x509.SubjectAlternativeName(
|
||||
[x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]
|
||||
),
|
||||
critical=False,
|
||||
)
|
||||
.sign(key, hashes.SHA256())
|
||||
)
|
||||
cert_file = cert_dir / "cert.pem"
|
||||
key_file = cert_dir / "key.pem"
|
||||
cert_file.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||
key_file.write_bytes(
|
||||
key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
return cert_file, key_file
|
||||
|
||||
|
||||
async def _asgi_app(scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
return
|
||||
while True:
|
||||
message = await receive()
|
||||
if message["type"] == "http.request" and not message.get("more_body"):
|
||||
break
|
||||
if message["type"] == "http.disconnect":
|
||||
return
|
||||
if scope["path"] == "/stream":
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 200,
|
||||
"headers": [(b"content-type", b"text/event-stream")],
|
||||
}
|
||||
)
|
||||
for index in range(3):
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.body",
|
||||
"body": f"data: chunk-{index}\n\n".encode(),
|
||||
"more_body": True,
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
||||
return
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 200,
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": b'{"ok": true}'})
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def http2_tls_server(tmp_path_factory):
|
||||
"""Hypercorn TLS server on an ephemeral port that negotiates h2 or http/1.1 via ALPN."""
|
||||
from hypercorn.asyncio import serve
|
||||
from hypercorn.config import Config
|
||||
|
||||
cert_dir = tmp_path_factory.mktemp("h2certs")
|
||||
cert_file, key_file = _write_self_signed_cert(cert_dir)
|
||||
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
|
||||
shutdown = threading.Event()
|
||||
|
||||
def _serve() -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
config = Config()
|
||||
config.bind = [f"127.0.0.1:{port}"]
|
||||
config.certfile = str(cert_file)
|
||||
config.keyfile = str(key_file)
|
||||
config.alpn_protocols = ["h2", "http/1.1"]
|
||||
loop.run_until_complete(serve(_asgi_app, config, shutdown_trigger=lambda: asyncio.to_thread(shutdown.wait)))
|
||||
loop.close()
|
||||
|
||||
thread = threading.Thread(target=_serve, daemon=True)
|
||||
thread.start()
|
||||
|
||||
for _ in range(100):
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
pytest.fail("hypercorn test server did not start")
|
||||
|
||||
yield f"https://127.0.0.1:{port}"
|
||||
|
||||
shutdown.set()
|
||||
thread.join(timeout=10)
|
||||
|
|
@ -1702,44 +1702,3 @@ async def test_http2_disabled_by_default(monkeypatch: pytest.MonkeyPatch):
|
|||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", False)
|
||||
|
||||
assert AsyncHTTPHandler._should_use_aiohttp_transport() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("use_http2", [True, False])
|
||||
@pytest.mark.parametrize("handler_kind", ["async", "sync"])
|
||||
async def test_http_version_negotiated_over_tls(monkeypatch, http2_tls_server, handler_kind, use_http2):
|
||||
monkeypatch.setattr(litellm, "http2", use_http2)
|
||||
monkeypatch.delenv("LITELLM_HTTP2", raising=False)
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "force_ipv4", False)
|
||||
|
||||
expected_version = "HTTP/2" if use_http2 else "HTTP/1.1"
|
||||
|
||||
if handler_kind == "async":
|
||||
handler = AsyncHTTPHandler(ssl_verify=False)
|
||||
try:
|
||||
response = await handler.post(f"{http2_tls_server}/echo", json={"ping": "pong"})
|
||||
assert response.status_code == 200
|
||||
assert response.http_version == expected_version
|
||||
|
||||
stream_response = await handler.post(f"{http2_tls_server}/stream", stream=True)
|
||||
assert stream_response.http_version == expected_version
|
||||
chunks = [chunk async for chunk in stream_response.aiter_bytes()]
|
||||
assert chunks
|
||||
await stream_response.aclose()
|
||||
finally:
|
||||
await handler.close()
|
||||
else:
|
||||
handler = HTTPHandler(ssl_verify=False)
|
||||
try:
|
||||
response = handler.post(f"{http2_tls_server}/echo", json={"ping": "pong"})
|
||||
assert response.status_code == 200
|
||||
assert response.http_version == expected_version
|
||||
|
||||
stream_response = handler.post(f"{http2_tls_server}/stream", stream=True)
|
||||
assert stream_response.http_version == expected_version
|
||||
chunks = list(stream_response.iter_bytes())
|
||||
assert chunks
|
||||
stream_response.close()
|
||||
finally:
|
||||
handler.close()
|
||||
|
|
|
|||
|
|
@ -413,18 +413,3 @@ def test_is_openai_backed_api_base_decides_by_hostname_only(api_base, expected):
|
|||
assert is_openai_backed_api_base(api_base) is expected
|
||||
|
||||
|
||||
def test_litellm_built_http_clients_negotiate_http2_only_when_enabled(monkeypatch):
|
||||
monkeypatch.delenv("LITELLM_HTTP2", raising=False)
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
|
||||
monkeypatch.setattr(litellm, "http2", False)
|
||||
async_client = BaseOpenAILLM._get_async_http_client()
|
||||
sync_client = BaseOpenAILLM._get_sync_http_client()
|
||||
assert async_client is not None and async_client._transport._pool._http2 is False
|
||||
assert sync_client is not None and sync_client._transport._pool._http2 is False
|
||||
|
||||
monkeypatch.setattr(litellm, "http2", True)
|
||||
async_client = BaseOpenAILLM._get_async_http_client()
|
||||
sync_client = BaseOpenAILLM._get_sync_http_client()
|
||||
assert async_client is not None and async_client._transport._pool._http2 is True
|
||||
assert sync_client is not None and sync_client._transport._pool._http2 is True
|
||||
|
|
|
|||
|
|
@ -190,9 +190,7 @@ async def test_url_with_format_param(model, sync_mode, monkeypatch):
|
|||
# URL->image conversion helpers so suite-level network/client state from
|
||||
# earlier tests cannot prevent the mocked provider client from being hit.
|
||||
fake_base64_image = "data:image/png;base64,ZmFrZS1pbWFnZQ=="
|
||||
monkeypatch.setattr(
|
||||
prompt_factory, "convert_url_to_base64", lambda url: fake_base64_image
|
||||
)
|
||||
monkeypatch.setattr(prompt_factory, "convert_url_to_base64", lambda url: fake_base64_image)
|
||||
monkeypatch.setattr(
|
||||
prompt_factory.BedrockImageProcessor,
|
||||
"get_image_details",
|
||||
|
|
@ -307,9 +305,7 @@ async def test_url_with_format_param_openai(model, sync_mode):
|
|||
}
|
||||
],
|
||||
}
|
||||
with patch.object(
|
||||
client.chat.completions.with_raw_response, "create"
|
||||
) as mock_client:
|
||||
with patch.object(client.chat.completions.with_raw_response, "create") as mock_client:
|
||||
try:
|
||||
if sync_mode:
|
||||
response = completion(**args, client=client)
|
||||
|
|
@ -361,9 +357,7 @@ def test_strip_input_examples_for_non_anthropic_providers():
|
|||
}
|
||||
]
|
||||
|
||||
assert not litellm_main._should_allow_input_examples(
|
||||
custom_llm_provider="openai", model="gpt-4o-mini"
|
||||
)
|
||||
assert not litellm_main._should_allow_input_examples(custom_llm_provider="openai", model="gpt-4o-mini")
|
||||
|
||||
cleaned = litellm_main._drop_input_examples_from_tools(tools=tools)
|
||||
|
||||
|
|
@ -375,9 +369,7 @@ def test_strip_input_examples_for_non_anthropic_providers():
|
|||
def test_custom_provider_with_extra_headers():
|
||||
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
||||
|
||||
with patch.object(
|
||||
litellm.llms.custom_httpx.http_handler.HTTPHandler, "post"
|
||||
) as mock_post:
|
||||
with patch.object(litellm.llms.custom_httpx.http_handler.HTTPHandler, "post") as mock_post:
|
||||
response = litellm.completion(
|
||||
model="custom/custom",
|
||||
messages=[{"role": "user", "content": "Hello, how are you?"}],
|
||||
|
|
@ -392,9 +384,7 @@ def test_custom_provider_with_extra_headers():
|
|||
def test_custom_provider_with_extra_body():
|
||||
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
||||
|
||||
with patch.object(
|
||||
litellm.llms.custom_httpx.http_handler.HTTPHandler, "post"
|
||||
) as mock_post:
|
||||
with patch.object(litellm.llms.custom_httpx.http_handler.HTTPHandler, "post") as mock_post:
|
||||
response = litellm.completion(
|
||||
model="custom/custom",
|
||||
messages=[{"role": "user", "content": "Hello, how are you?"}],
|
||||
|
|
@ -421,9 +411,7 @@ def test_custom_provider_with_extra_body():
|
|||
}
|
||||
|
||||
# test that extra_body is not passed if not provided
|
||||
with patch.object(
|
||||
litellm.llms.custom_httpx.http_handler.HTTPHandler, "post"
|
||||
) as mock_post:
|
||||
with patch.object(litellm.llms.custom_httpx.http_handler.HTTPHandler, "post") as mock_post:
|
||||
response = litellm.completion(
|
||||
model="custom/custom",
|
||||
messages=[{"role": "user", "content": "Hello, how are you?"}],
|
||||
|
|
@ -454,9 +442,7 @@ def set_openrouter_api_key():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extra_body_with_fallback(
|
||||
respx_mock: respx.MockRouter, set_openrouter_api_key, monkeypatch
|
||||
):
|
||||
async def test_extra_body_with_fallback(respx_mock: respx.MockRouter, set_openrouter_api_key, monkeypatch):
|
||||
"""
|
||||
test regression for https://github.com/BerriAI/litellm/issues/8425.
|
||||
|
||||
|
|
@ -524,9 +510,7 @@ async def test_extra_body_with_fallback(
|
|||
|
||||
# Verify the response
|
||||
assert response is not None
|
||||
assert (
|
||||
len(respx_mock.calls) > 0
|
||||
), "Mock was not called - check if aiohttp transport is properly disabled"
|
||||
assert len(respx_mock.calls) > 0, "Mock was not called - check if aiohttp transport is properly disabled"
|
||||
|
||||
# Get the request from the mock
|
||||
request: httpx.Request = respx_mock.calls[0].request
|
||||
|
|
@ -550,9 +534,7 @@ async def test_extra_body_with_fallback(
|
|||
@pytest.mark.parametrize("env_base", ["OPENAI_BASE_URL", "OPENAI_API_BASE"])
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_openai_env_base(
|
||||
respx_mock: respx.MockRouter, env_base, openai_api_response, monkeypatch
|
||||
):
|
||||
async def test_openai_env_base(respx_mock: respx.MockRouter, env_base, openai_api_response, monkeypatch):
|
||||
"This tests OpenAI env variables are honored, including legacy OPENAI_API_BASE"
|
||||
# Ensure aiohttp transport is disabled to use httpx which respx can mock
|
||||
litellm.disable_aiohttp_transport = True
|
||||
|
|
@ -567,9 +549,7 @@ async def test_openai_env_base(
|
|||
messages = [{"role": "user", "content": "Hello, how are you?"}]
|
||||
|
||||
# Configure respx mock to intercept the request
|
||||
mock_route = respx_mock.post(
|
||||
url__regex=r"http://localhost:12345/v1/chat/completions.*"
|
||||
).mock(
|
||||
mock_route = respx_mock.post(url__regex=r"http://localhost:12345/v1/chat/completions.*").mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
|
|
@ -603,9 +583,7 @@ async def test_openai_env_base(
|
|||
assert response.choices[0].message.content == "Hello from mocked response!"
|
||||
|
||||
# Verify the mock was called
|
||||
assert (
|
||||
mock_route.called
|
||||
), "Mock route was not called - request may have bypassed respx"
|
||||
assert mock_route.called, "Mock route was not called - request may have bypassed respx"
|
||||
finally:
|
||||
# Clean up to avoid affecting other tests
|
||||
litellm.disable_aiohttp_transport = False
|
||||
|
|
@ -681,9 +659,7 @@ def test_completion_forwards_verbosity_in_raw_request(respx_mock: respx.MockRout
|
|||
|
||||
model = "gpt-5.2"
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
return_value=_mocked_openai_chat_response(model)
|
||||
)
|
||||
respx_mock.post("https://api.openai.com/v1/chat/completions").mock(return_value=_mocked_openai_chat_response(model))
|
||||
|
||||
request = return_raw_request(
|
||||
endpoint=CallTypes.completion,
|
||||
|
|
@ -700,9 +676,7 @@ def test_completion_forwards_verbosity_in_raw_request(respx_mock: respx.MockRout
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_forwards_verbosity_to_provider_request(
|
||||
respx_mock: respx.MockRouter, monkeypatch
|
||||
):
|
||||
async def test_acompletion_forwards_verbosity_to_provider_request(respx_mock: respx.MockRouter, monkeypatch):
|
||||
"""Regression test: acompletion() must forward the verbosity param to the provider request body."""
|
||||
original_disable_aiohttp = litellm.disable_aiohttp_transport
|
||||
try:
|
||||
|
|
@ -763,9 +737,9 @@ def test_responses_api_bridge_check_gpt_5_4_pro():
|
|||
model=model_name,
|
||||
custom_llm_provider="openai",
|
||||
)
|
||||
assert (
|
||||
model_info.get("mode") == "responses"
|
||||
), f"{model_name} should have mode='responses', got '{model_info.get('mode')}'"
|
||||
assert model_info.get("mode") == "responses", (
|
||||
f"{model_name} should have mode='responses', got '{model_info.get('mode')}'"
|
||||
)
|
||||
|
||||
|
||||
def test_responses_api_bridge_check_gpt_5_4_tools_plus_reasoning_routes_to_responses():
|
||||
|
|
@ -1182,7 +1156,7 @@ def test_responses_api_bridge_check_openai_backed_custom_api_base_with_unset_eff
|
|||
tools=[{"type": "function", "function": {"name": "get_capital"}}],
|
||||
reasoning_effort=None,
|
||||
api_base=api_base,
|
||||
)
|
||||
)
|
||||
|
||||
assert model == "gpt-5.6"
|
||||
assert model_info.get("mode") == "responses"
|
||||
|
|
@ -1207,7 +1181,7 @@ def test_responses_api_bridge_check_lookalike_custom_api_base_with_unset_effort_
|
|||
tools=[{"type": "function", "function": {"name": "get_capital"}}],
|
||||
reasoning_effort=None,
|
||||
api_base=api_base,
|
||||
)
|
||||
)
|
||||
|
||||
assert model == "gpt-5.6"
|
||||
assert model_info.get("mode") != "responses"
|
||||
|
|
@ -1227,7 +1201,7 @@ def test_responses_api_bridge_check_privatelink_api_base_via_env_with_unset_effo
|
|||
tools=[{"type": "function", "function": {"name": "get_capital"}}],
|
||||
reasoning_effort=None,
|
||||
api_base=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert model == "gpt-5.6"
|
||||
assert model_info.get("mode") == "responses"
|
||||
|
|
@ -1588,9 +1562,7 @@ def test_responses_api_bridge_check_handles_exception():
|
|||
with patch("litellm.main._get_model_info_helper") as mock_get_model_info:
|
||||
mock_get_model_info.side_effect = Exception("Model not found")
|
||||
|
||||
model_info, model = responses_api_bridge_check(
|
||||
model="responses/custom-model", custom_llm_provider="custom"
|
||||
)
|
||||
model_info, model = responses_api_bridge_check(model="responses/custom-model", custom_llm_provider="custom")
|
||||
|
||||
assert model == "custom-model"
|
||||
assert model_info["mode"] == "responses"
|
||||
|
|
@ -2371,9 +2343,7 @@ def test_image_edit_merges_headers_and_extra_headers():
|
|||
|
||||
mock_image_edit_config = MagicMock()
|
||||
mock_image_edit_config.get_supported_openai_params.return_value = set()
|
||||
mock_image_edit_config.map_openai_params.side_effect = lambda **kwargs: dict(
|
||||
kwargs["image_edit_optional_params"]
|
||||
)
|
||||
mock_image_edit_config.map_openai_params.side_effect = lambda **kwargs: dict(kwargs["image_edit_optional_params"])
|
||||
|
||||
with (
|
||||
patch(
|
||||
|
|
@ -2729,10 +2699,7 @@ def test_mock_completion_stream_with_model_response():
|
|||
# Verify the content is streamed correctly
|
||||
accumulated_content = ""
|
||||
for chunk in chunks:
|
||||
if (
|
||||
hasattr(chunk.choices[0].delta, "content")
|
||||
and chunk.choices[0].delta.content
|
||||
):
|
||||
if hasattr(chunk.choices[0].delta, "content") and chunk.choices[0].delta.content:
|
||||
accumulated_content += chunk.choices[0].delta.content
|
||||
|
||||
assert "This is a test response" in accumulated_content or len(chunks) > 0
|
||||
|
|
@ -2790,10 +2757,7 @@ async def test_async_mock_completion_stream_with_model_response():
|
|||
# Verify the content is streamed correctly
|
||||
accumulated_content = ""
|
||||
for chunk in chunks:
|
||||
if (
|
||||
hasattr(chunk.choices[0].delta, "content")
|
||||
and chunk.choices[0].delta.content
|
||||
):
|
||||
if hasattr(chunk.choices[0].delta, "content") and chunk.choices[0].delta.content:
|
||||
accumulated_content += chunk.choices[0].delta.content
|
||||
|
||||
assert "This is an async test response" in accumulated_content or len(chunks) > 0
|
||||
|
|
@ -2860,9 +2824,7 @@ def test_stream_chunk_builder_text_completion_combines_text_and_usage():
|
|||
),
|
||||
]
|
||||
|
||||
response = stream_chunk_builder_text_completion(
|
||||
chunks=chunks, messages=[{"role": "user", "content": "say hello"}]
|
||||
)
|
||||
response = stream_chunk_builder_text_completion(chunks=chunks, messages=[{"role": "user", "content": "say hello"}])
|
||||
|
||||
assert response.choices[0].text == "Hello world"
|
||||
assert response.choices[0].finish_reason == "stop"
|
||||
|
|
@ -3310,10 +3272,7 @@ def _text_chunk(content, finish_reason=None, usage=None):
|
|||
|
||||
def _priced_at(prompt_tokens, completion_tokens):
|
||||
prices = litellm.model_cost[STREAM_COST_MODEL]
|
||||
return (
|
||||
prompt_tokens * prices["input_cost_per_token"]
|
||||
+ completion_tokens * prices["output_cost_per_token"]
|
||||
)
|
||||
return prompt_tokens * prices["input_cost_per_token"] + completion_tokens * prices["output_cost_per_token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -3380,9 +3339,9 @@ def test_streaming_and_not_streaming_bill_the_same_usage_the_same(local_cost_map
|
|||
usage=STREAMED_USAGE,
|
||||
)
|
||||
|
||||
assert litellm.completion_cost(
|
||||
completion_response=rebuilt, model=STREAM_COST_MODEL
|
||||
) == pytest.approx(litellm.completion_cost(completion_response=whole, model=STREAM_COST_MODEL))
|
||||
assert litellm.completion_cost(completion_response=rebuilt, model=STREAM_COST_MODEL) == pytest.approx(
|
||||
litellm.completion_cost(completion_response=whole, model=STREAM_COST_MODEL)
|
||||
)
|
||||
|
||||
|
||||
def test_a_stream_that_reported_no_usage_is_still_billed(local_cost_map):
|
||||
|
|
@ -3401,9 +3360,7 @@ def test_a_stream_that_reported_no_usage_is_still_billed(local_cost_map):
|
|||
cost = litellm.completion_cost(completion_response=rebuilt, model=STREAM_COST_MODEL)
|
||||
|
||||
assert cost > 0
|
||||
assert cost == pytest.approx(
|
||||
_priced_at(rebuilt.usage.prompt_tokens, rebuilt.usage.completion_tokens)
|
||||
)
|
||||
assert cost == pytest.approx(_priced_at(rebuilt.usage.prompt_tokens, rebuilt.usage.completion_tokens))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -3850,3 +3807,29 @@ def test_bridged_responses_with_openai_http_handler_keeps_forwarded_headers_out_
|
|||
assert "extra_headers" not in body
|
||||
assert body["model"] == "gpt-5.4"
|
||||
assert {k: request.headers[k] for k in FORWARDED_CLIENT_HEADERS} == FORWARDED_CLIENT_HEADERS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("http2_on", [True, False])
|
||||
def test_aiohttp_openai_warns_only_when_http2_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, http2_on: bool
|
||||
):
|
||||
import logging
|
||||
|
||||
from litellm.main import base_llm_aiohttp_handler
|
||||
|
||||
monkeypatch.setattr(litellm, "http2", http2_on)
|
||||
monkeypatch.delenv("LITELLM_HTTP2", raising=False)
|
||||
|
||||
handler_completion: Final = MagicMock(return_value=MagicMock())
|
||||
monkeypatch.setattr(base_llm_aiohttp_handler, "completion", handler_completion)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
|
||||
litellm.completion(
|
||||
model="aiohttp_openai/gpt-4o",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
api_key="sk-test",
|
||||
)
|
||||
|
||||
assert handler_completion.called
|
||||
warned: Final = "aiohttp_openai/ always uses aiohttp" in caplog.text
|
||||
assert warned is http2_on
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue