Spaces:
Runtime error
Runtime error
| """Unit tests for the Gemini provider lane in app.llm.client — no network. | |
| httpx.Client is monkeypatched with a fake that returns canned generateContent | |
| payloads; the assertions cover provider resolution, structured parsing, refusal | |
| and truncation mapping, markdown-fence salvage, transport degradation, and the | |
| audit trail rows the shared record() path writes. | |
| """ | |
| import json | |
| from pathlib import Path | |
| from typing import Any | |
| import httpx | |
| import pytest | |
| from pydantic import ValidationError | |
| from sqlalchemy import select | |
| from sqlalchemy.orm import Session | |
| from app.config import Settings | |
| from app.llm.client import ( | |
| LLMRefusalError, | |
| LLMUnavailableError, | |
| generate, | |
| llm_available, | |
| resolve_provider, | |
| ) | |
| from app.llm.schemas import ClaimantEmailLLM | |
| from app.models import AuditEvent | |
| PARSED = ClaimantEmailLLM( | |
| subject="Claim update", greeting="Hi Casey,", body_paragraphs=["All good."], closing="ClaimFlow" | |
| ) | |
| PARSED_JSON = PARSED.model_dump_json() | |
| def settings(tmp_path: Path) -> Settings: | |
| return Settings( | |
| database_url=f"sqlite:///{tmp_path}/test.sqlite", | |
| upload_dir=tmp_path / "uploads", | |
| anthropic_api_key="", | |
| gemini_api_key="test-gemini-key", | |
| chroma_dir=tmp_path / "chroma", | |
| ) | |
| def _gemini_ok(text: str = PARSED_JSON, finish: str = "STOP") -> dict[str, Any]: | |
| return { | |
| "candidates": [{"content": {"parts": [{"text": text}]}, "finishReason": finish}], | |
| "usageMetadata": {"promptTokenCount": 120, "candidatesTokenCount": 40}, | |
| } | |
| def _gemini_blocked() -> dict[str, Any]: | |
| return {"promptFeedback": {"blockReason": "SAFETY"}, "candidates": []} | |
| class _FakeResponse: | |
| def __init__(self, payload: dict[str, Any], status_code: int = 200) -> None: | |
| self._payload = payload | |
| self.status_code = status_code | |
| self.text = json.dumps(payload) | |
| def json(self) -> dict[str, Any]: | |
| return self._payload | |
| def raise_for_status(self) -> None: | |
| if self.status_code >= 400: | |
| raise httpx.HTTPStatusError("error", request=None, response=None) # type: ignore[arg-type] | |
| class _FakeClient: | |
| """Stands in for httpx.Client; pops one canned response per post call.""" | |
| queue: list[Any] = [] | |
| calls: list[dict[str, Any]] = [] | |
| def __init__(self, *args: Any, **kwargs: Any) -> None: ... | |
| def __enter__(self) -> "_FakeClient": | |
| return self | |
| def __exit__(self, *exc: Any) -> None: ... | |
| def post(self, url: str, *, json: dict[str, Any], headers: dict[str, str]) -> _FakeResponse: | |
| _FakeClient.calls.append({"url": url, "body": json, "headers": headers}) | |
| item = _FakeClient.queue.pop(0) | |
| if isinstance(item, Exception): | |
| raise item | |
| return item | |
| def fake_http(monkeypatch: pytest.MonkeyPatch) -> type[_FakeClient]: | |
| _FakeClient.queue = [] | |
| _FakeClient.calls = [] | |
| monkeypatch.setattr(httpx, "Client", _FakeClient) | |
| return _FakeClient | |
| def test_provider_resolution(settings: Settings, tmp_path: Path) -> None: | |
| assert resolve_provider(settings) == "gemini" | |
| assert llm_available(settings) is True | |
| both = settings.model_copy(update={"anthropic_api_key": "sk-ant-x"}) | |
| assert resolve_provider(both) == "anthropic" | |
| neither = settings.model_copy(update={"gemini_api_key": ""}) | |
| assert resolve_provider(neither) is None | |
| assert llm_available(neither) is False | |
| def _generate(settings: Settings, session: Session): | |
| return generate( | |
| settings, | |
| session, | |
| route_name="claimant_email", | |
| system="You draft emails.", | |
| user_content=[{"type": "text", "text": "Draft the decision email."}], | |
| schema=ClaimantEmailLLM, | |
| claim_id=None, | |
| prompt_version="v1", | |
| prompt_sha256="0" * 64, | |
| ) | |
| def test_gemini_happy_path_audits_and_parses( | |
| settings: Settings, session: Session, fake_http: type[_FakeClient] | |
| ) -> None: | |
| fake_http.queue = [_FakeResponse(_gemini_ok())] | |
| result = _generate(settings, session) | |
| session.commit() | |
| assert result.parsed == PARSED | |
| assert result.model == "gemini-2.5-flash" | |
| assert result.stop_reason == "end_turn" | |
| assert result.cost_usd == 0.0 # free tier; unknown model prices as zero | |
| assert result.input_tokens == 120 and result.output_tokens == 40 | |
| sent = fake_http.calls[0] | |
| assert "gemini-2.5-flash:generateContent" in sent["url"] | |
| assert sent["headers"]["x-goog-api-key"] == "test-gemini-key" | |
| assert sent["body"]["generationConfig"]["responseMimeType"] == "application/json" | |
| events = session.scalars(select(AuditEvent)).all() | |
| assert len(events) == 1 | |
| payload = json.loads(events[0].payload_json) | |
| assert payload["model"] == "gemini-2.5-flash" | |
| assert payload["stop_reason"] == "end_turn" | |
| def test_gemini_refusal_maps_to_llm_refusal( | |
| settings: Settings, session: Session, fake_http: type[_FakeClient] | |
| ) -> None: | |
| fake_http.queue = [_FakeResponse(_gemini_blocked())] | |
| with pytest.raises(LLMRefusalError): | |
| _generate(settings, session) | |
| session.commit() | |
| payload = json.loads(session.scalars(select(AuditEvent)).one().payload_json) | |
| assert payload["stop_reason"] == "refusal" | |
| def test_gemini_truncation_retries_with_doubled_budget( | |
| settings: Settings, session: Session, fake_http: type[_FakeClient] | |
| ) -> None: | |
| fake_http.queue = [ | |
| _FakeResponse(_gemini_ok(finish="MAX_TOKENS")), | |
| _FakeResponse(_gemini_ok()), | |
| ] | |
| result = _generate(settings, session) | |
| session.commit() | |
| assert result.retried is True | |
| assert result.parsed == PARSED | |
| first, second = (c["body"]["generationConfig"]["maxOutputTokens"] for c in fake_http.calls) | |
| assert second == first * 2 | |
| stops = [ | |
| json.loads(e.payload_json)["stop_reason"] for e in session.scalars(select(AuditEvent)) | |
| ] | |
| assert stops == ["max_tokens", "end_turn"] | |
| def test_gemini_bad_json_raises_validation_error_after_audit( | |
| settings: Settings, session: Session, fake_http: type[_FakeClient] | |
| ) -> None: | |
| fake_http.queue = [_FakeResponse(_gemini_ok(text='{"subject": "missing fields"}'))] | |
| with pytest.raises(ValidationError): | |
| _generate(settings, session) | |
| session.commit() | |
| payload = json.loads(session.scalars(select(AuditEvent)).one().payload_json) | |
| assert payload["stop_reason"] == "parse_error" | |
| def test_gemini_markdown_fenced_json_is_salvaged( | |
| settings: Settings, session: Session, fake_http: type[_FakeClient] | |
| ) -> None: | |
| fenced = f"```json\n{PARSED_JSON}\n```" | |
| fake_http.queue = [_FakeResponse(_gemini_ok(text=fenced))] | |
| result = _generate(settings, session) | |
| assert result.parsed == PARSED | |
| def test_gemini_transport_error_degrades_to_unavailable( | |
| settings: Settings, session: Session, fake_http: type[_FakeClient] | |
| ) -> None: | |
| fake_http.queue = [httpx.ConnectError("boom")] | |
| with pytest.raises(LLMUnavailableError): | |
| _generate(settings, session) | |