203 lines
7.1 KiB
Python
203 lines
7.1 KiB
Python
"""Tests for the public `utils.http_context.open()` helper and for the
|
|
inference STT error surface when called outside a job context.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import ssl
|
|
|
|
import aiohttp
|
|
import certifi
|
|
import pytest
|
|
|
|
from livekit.agents import inference
|
|
from livekit.agents.utils import http_context
|
|
|
|
pytestmark = [pytest.mark.unit, pytest.mark.concurrent]
|
|
|
|
|
|
async def test_open_yields_working_session_and_closes_on_exit() -> None:
|
|
with pytest.raises(RuntimeError):
|
|
http_context.http_session()
|
|
|
|
async with http_context.open() as session:
|
|
assert isinstance(session, aiohttp.ClientSession)
|
|
assert not session.closed
|
|
# http_session() returns the same instance inside the block
|
|
assert http_context.http_session() is session
|
|
|
|
assert session.closed
|
|
with pytest.raises(RuntimeError):
|
|
http_context.http_session()
|
|
|
|
|
|
async def test_open_is_reentrant_inner_does_not_close_outer() -> None:
|
|
async with http_context.open() as outer:
|
|
async with http_context.open() as inner:
|
|
# nested open() reuses the outer session — does not create a new one
|
|
assert inner is outer
|
|
|
|
# outer session is untouched after inner exits
|
|
assert not outer.closed
|
|
assert http_context.http_session() is outer
|
|
|
|
assert outer.closed
|
|
|
|
|
|
async def test_open_isolated_per_task() -> None:
|
|
"""Each asyncio.Task gets its own http session context — they don't share."""
|
|
barrier = asyncio.Event()
|
|
|
|
async def worker() -> tuple[aiohttp.ClientSession, bool]:
|
|
async with http_context.open() as session:
|
|
await barrier.wait()
|
|
still_open = not session.closed
|
|
return session, still_open
|
|
|
|
task_a = asyncio.create_task(worker())
|
|
task_b = asyncio.create_task(worker())
|
|
await asyncio.sleep(0.01)
|
|
barrier.set()
|
|
|
|
(sess_a, a_open), (sess_b, b_open) = await asyncio.gather(task_a, task_b)
|
|
assert sess_a is not sess_b
|
|
assert a_open and b_open
|
|
assert sess_a.closed and sess_b.closed
|
|
|
|
|
|
async def test_http_session_error_message_points_to_helper() -> None:
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
http_context.http_session()
|
|
msg = str(exc_info.value)
|
|
assert "http_context.open()" in msg
|
|
|
|
|
|
def _certifi_cert_count() -> int:
|
|
ctx = ssl.create_default_context(cafile=certifi.where())
|
|
return ctx.cert_store_stats()["x509"]
|
|
|
|
|
|
def test_ssl_context_honors_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""SSL_CERT_FILE / SSL_CERT_DIR take precedence over the system store."""
|
|
monkeypatch.setenv("SSL_CERT_FILE", certifi.where())
|
|
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
|
|
|
|
ctx = http_context._create_ssl_context()
|
|
assert ctx.cert_store_stats()["x509"] == _certifi_cert_count()
|
|
|
|
|
|
def test_ssl_context_falls_back_to_certifi_without_system_store(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""When the host has no resolvable system trust store, certifi is loaded.
|
|
|
|
Asserts on the actual ``load_verify_locations(certifi.where())`` call, not
|
|
cert counts: ``create_default_context()`` loads OpenSSL's compiled-in roots
|
|
at the C level regardless of the ``get_default_verify_paths`` monkeypatch,
|
|
so a count check would pass on a host with a system store even if the
|
|
certifi branch never ran.
|
|
"""
|
|
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
|
|
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
|
|
monkeypatch.setattr(
|
|
ssl,
|
|
"get_default_verify_paths",
|
|
lambda: ssl.DefaultVerifyPaths(
|
|
cafile="/nonexistent/cert.pem",
|
|
capath="/nonexistent/certs",
|
|
openssl_cafile_env="SSL_CERT_FILE",
|
|
openssl_cafile="/nonexistent/cert.pem",
|
|
openssl_capath_env="SSL_CERT_DIR",
|
|
openssl_capath="/nonexistent/certs",
|
|
),
|
|
)
|
|
|
|
loaded_cafiles: list[str | None] = []
|
|
real_load = ssl.SSLContext.load_verify_locations
|
|
|
|
def _spy(self: ssl.SSLContext, *args: object, **kwargs: object) -> object:
|
|
loaded_cafiles.append(kwargs.get("cafile")) # type: ignore[arg-type]
|
|
return real_load(self, *args, **kwargs) # type: ignore[arg-type]
|
|
|
|
monkeypatch.setattr(ssl.SSLContext, "load_verify_locations", _spy)
|
|
|
|
http_context._create_ssl_context()
|
|
assert certifi.where() in loaded_cafiles
|
|
|
|
|
|
def test_ssl_context_uses_system_store_when_present(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A resolvable system store is used as-is, without forcing certifi in."""
|
|
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
|
|
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
|
|
|
|
if not http_context._has_system_trust_store():
|
|
pytest.skip("host has no system trust store to exercise this path")
|
|
|
|
ctx = http_context._create_ssl_context()
|
|
assert ctx.verify_mode == ssl.CERT_REQUIRED
|
|
|
|
|
|
def test_set_default_cert_env_sets_var_without_system_store(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""With no env override and no system store, SSL_CERT_FILE is set to certifi."""
|
|
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
|
|
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
|
|
monkeypatch.setattr(http_context, "_has_system_trust_store", lambda: False)
|
|
|
|
http_context._set_default_cert_env()
|
|
assert os.environ.get("SSL_CERT_FILE") == certifi.where()
|
|
|
|
|
|
def test_set_default_cert_env_noop_with_system_store(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A resolvable system store is left alone — SSL_CERT_FILE stays unset."""
|
|
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
|
|
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
|
|
monkeypatch.setattr(http_context, "_has_system_trust_store", lambda: True)
|
|
|
|
http_context._set_default_cert_env()
|
|
assert "SSL_CERT_FILE" not in os.environ
|
|
|
|
|
|
def test_set_default_cert_env_respects_existing_override(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""An explicit SSL_CERT_FILE is never overwritten, even with no system store."""
|
|
monkeypatch.setenv("SSL_CERT_FILE", "/custom/bundle.pem")
|
|
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
|
|
monkeypatch.setattr(http_context, "_has_system_trust_store", lambda: False)
|
|
|
|
http_context._set_default_cert_env()
|
|
assert os.environ["SSL_CERT_FILE"] == "/custom/bundle.pem"
|
|
|
|
|
|
async def test_inference_stt_surfaces_real_error_outside_ctx(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Regression: previously, calling `inference.STT().stream()` outside a job
|
|
context raised `AttributeError: 'SpeechStream' object has no attribute
|
|
'_session'` from a background task — masking the real "no http context" error.
|
|
|
|
After the fix, _ensure_session() runs inside _run(), so the actual
|
|
RuntimeError surfaces through the stream's main task.
|
|
"""
|
|
monkeypatch.setenv("LIVEKIT_API_KEY", "test-key")
|
|
monkeypatch.setenv("LIVEKIT_API_SECRET", "test-secret")
|
|
|
|
stt = inference.STT(model="cartesia/sonic-3")
|
|
stream = stt.stream()
|
|
|
|
# SpeechStream no longer eagerly grabs `_session` in __init__.
|
|
assert not hasattr(stream, "_session")
|
|
|
|
with pytest.raises(RuntimeError, match="http_context.open"):
|
|
await stream._task
|
|
|
|
await stream.aclose()
|