Ctrl K
ComunidadGuías…

tests/backends/providers/test_live.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Live smoke tests: each adapter against a real gateway (``-m live``).

Same env contract as the 0.6 live suite: ``NOETA_LIVE_BASE_URL``,
``NOETA_LIVE_API_KEY``, ``NOETA_LIVE_MODEL`` (optional
``NOETA_LIVE_MAX_TOKENS``), read from the environment or a repo-root ``.env``
(git-ignored). One gateway, three endpoints: ``/v1/messages``,
``/v1/chat/completions``, ``/v1/responses``. Missing any of base/key/model
skips. Assertions check structure only — a real model's wording is not
something a test can pin.
"""

from __future__ import annotations

import os
from pathlib import Path

import pytest

from noeta.backends.providers import Provider, provider_for
from noeta.core.retry import complete_with_retries
from noeta.core.types import (
    Delta,
    Message,
    ModelRequest,
    SystemPart,
    Text,
    ToolResult,
    ToolSpec,
    user,
)

pytestmark = pytest.mark.live


def _load_dotenv() -> None:
    """``KEY=VALUE`` lines from the nearest ``.env`` up the tree, without
    clobbering variables already set."""
    for parent in Path(__file__).resolve().parents:
        path = parent / ".env"
        if path.is_file():
            for raw in path.read_text(encoding="utf-8").splitlines():
                line = raw.strip()
                if not line or line.startswith("#") or "=" not in line:
                    continue
                key, _, value = line.partition("=")
                if key.strip():
                    os.environ.setdefault(key.strip(), value.strip().strip("'\""))
            return


_load_dotenv()
BASE = (os.environ.get("NOETA_LIVE_BASE_URL") or "").rstrip("/")
KEY = os.environ.get("NOETA_LIVE_API_KEY") or ""
MODEL = os.environ.get("NOETA_LIVE_MODEL") or ""
MAX_TOKENS = int(os.environ.get("NOETA_LIVE_MAX_TOKENS") or 1024)

requires_live = pytest.mark.skipif(
    not (BASE and KEY and MODEL),
    reason="live tests need NOETA_LIVE_BASE_URL / NOETA_LIVE_API_KEY / NOETA_LIVE_MODEL",
)


def _build(family: str) -> Provider:
    if family == "anthropic":
        return provider_for("anthropic", base_url=BASE, api_key=KEY)
    if family == "openai_compat":
        return provider_for("openai_compat", base_url=f"{BASE}/v1", api_key=KEY)
    # The adapter sends an Azure-style ``api-key``; a gateway fronting the
    # OpenAI shape may want a Bearer token, which rides extra_headers.
    return provider_for(
        "openai_responses",
        base_url=f"{BASE}/v1/responses",
        api_key=KEY,
        extra_headers={"Authorization": f"Bearer {KEY}"},
    )


_WEATHER = ToolSpec(
    name="get_weather",
    description="Get the current weather for a city.",
    input_schema={
        "type": "object",
        "properties": {"city": {"type": "string"}},
        "required": ["city"],
    },
)

FAMILIES = ["anthropic", "openai_compat", "openai_responses"]


@requires_live
@pytest.mark.parametrize("family", FAMILIES)
async def test_live_streaming_text(family: str) -> None:
    provider = _build(family)
    deltas: list[Delta] = []
    response = await complete_with_retries(
        provider,
        ModelRequest(
            model=MODEL,
            system=[SystemPart(text="You are terse.", cache=True)],
            messages=[user("Reply with the single word: pong")],
            max_tokens=MAX_TOKENS,
        ),
        deltas.append,
        max_attempts=3,
    )
    assert response.stop_reason in ("end_turn", "max_tokens")
    assert response.message.text.strip()
    assert response.usage.input > 0
    assert response.usage.output > 0
    streamed = "".join(d.text for d in deltas if d.kind == "text")
    assert streamed == response.message.text


@requires_live
@pytest.mark.parametrize("family", FAMILIES)
async def test_live_tool_round_trip(family: str) -> None:
    provider = _build(family)
    first_request = ModelRequest(
        model=MODEL,
        system=[SystemPart(text="Use the get_weather tool to answer weather questions.")],
        messages=[user("What's the weather in Paris? Use the tool.")],
        tools=[_WEATHER],
        max_tokens=MAX_TOKENS,
    )
    first = await complete_with_retries(provider, first_request, max_attempts=3)
    assert first.stop_reason == "tool_use"
    calls = first.message.tool_uses
    assert calls and calls[0].name == "get_weather"
    assert isinstance(calls[0].input, dict)

    results = Message(
        role="user",
        content=[
            ToolResult(call_id=call.id, content=[Text(text="Sunny, 21 C")])
            for call in calls
        ],
    )
    second = await complete_with_retries(
        provider,
        ModelRequest(
            model=MODEL,
            system=first_request.system,
            messages=[*first_request.messages, first.message, results],
            tools=[_WEATHER],
            max_tokens=MAX_TOKENS,
            cache_after=[2],
        ),
        max_attempts=3,
    )
    assert second.stop_reason in ("end_turn", "max_tokens")
    assert second.message.text.strip()