From 37f4c349deddb90ef88c43c8ec786a16b0930f0a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 12:46:28 +0000 Subject: [PATCH 1/2] Add Claude (Anthropic) LLM provider for explain/ask LLM_PROVIDER=claude drives explain/ask via the Messages API over raw httpx, matching OpenAI's single-attempt HTTP plus G10 resilience wrap. An empty ANTHROPIC_API_KEY falls back to noop so the tool still runs without a key. Closes #35. Co-authored-by: Leonardo --- .env.example | 13 +- README.md | 22 ++- docker-compose.yml | 4 + src/api/overrides.py | 8 +- src/api/routes/health.py | 11 +- src/cli/commands/config_cmd.py | 2 + src/cli/commands/keys.py | 4 +- src/cli/commands/status.py | 10 +- src/config/settings.py | 4 +- src/core/llm/provider.py | 50 ++++- src/core/llm/resilience.py | 2 +- src/core/retrieval/question_router.py | 7 +- src/observability/logging.py | 1 + tests/unit/test_api.py | 2 +- tests/unit/test_llm_provider.py | 272 ++++++++++++++++++++++++++ tests/unit/test_overrides.py | 44 ++++- tests/unit/test_settings.py | 31 +++ 17 files changed, 459 insertions(+), 28 deletions(-) create mode 100644 tests/unit/test_llm_provider.py diff --git a/.env.example b/.env.example index 5a1da8e..0300170 100644 --- a/.env.example +++ b/.env.example @@ -17,13 +17,14 @@ DB_URL=postgresql+psycopg://postgres:postgres@localhost:5432/raglogs # ── LLM provider ───────────────────────────────────────────────────────────── # Controls the explain and ask commands. -# Options: disabled | openai | ollama +# Options: disabled | openai | ollama | claude # Default: disabled (deterministic rules-based output, no API calls) LLM_PROVIDER=disabled -# Model to use when LLM_PROVIDER is openai or ollama. +# Model to use when LLM_PROVIDER is openai, ollama, or claude. # OpenAI examples : gpt-4.1-mini gpt-4.1 gpt-4o # Ollama examples : llama3 mistral phi3 +# Claude examples : claude-haiku-4-5 (recommended default for LLM_PROVIDER=claude) LLM_MODEL=gpt-4.1-mini # API key and base URL for OpenAI (used when LLM_PROVIDER=openai or EMBEDDINGS_PROVIDER=openai). @@ -33,6 +34,12 @@ OPENAI_BASE_URL=https://api.openai.com/v1 # When LLM_PROVIDER=ollama, set the Ollama server URL. OLLAMA_BASE_URL=http://localhost:11434 +# When LLM_PROVIDER=claude, set the Anthropic API key. Empty key falls back to +# the noop (template) provider. Override the endpoint only for proxies. +# LLM_MODEL=claude-haiku-4-5 +ANTHROPIC_API_KEY= +ANTHROPIC_BASE_URL=https://api.anthropic.com + # ── Embeddings provider ─────────────────────────────────────────────────────── # Controls semantic cluster merging at analysis time (explain / clusters / @@ -124,7 +131,7 @@ INGEST_RETRY_AFTER_SECONDS=5 # back to deterministic templates and set llm.fell_back=true; the request succeeds. # LLM_TIMEOUT=30 # LLM_MAX_RETRIES=2 # extra attempts after the first (3 total) -# LLM_MAX_TOKENS=600 # completion cap (OpenAI max_tokens / Ollama num_predict) +# LLM_MAX_TOKENS=600 # completion cap (OpenAI/Claude max_tokens / Ollama num_predict) # LLM_MAX_INPUT_TOKENS=0 # 0 = derive from LLM_MAX_TOKENS (chars/4 estimate) # LLM_BREAKER_THRESHOLD=5 # consecutive failures before the breaker opens # LLM_BREAKER_COOLDOWN_SECONDS=60 diff --git a/README.md b/README.md index 3374c13..2653a69 100644 --- a/README.md +++ b/README.md @@ -680,7 +680,7 @@ raglogs keys revoke | `--max-clusters` | Per-key default `max_clusters` (1–100) stored on `api_keys.config_json` | | `--max-evidence-items` | Per-key default `max_evidence_items` (1–50) | | `--baseline-window` | Per-key default baseline duration (e.g. `24h`) | -| `--llm-provider` | Per-key default `openai` / `ollama` / `disabled` | +| `--llm-provider` | Per-key default `openai` / `ollama` / `claude` / `disabled` | | `--llm-enabled` / `--no-llm-enabled` | Per-key default for whether the LLM is used | `raglogs keys set-defaults` merges flags into the key's `config_json`. `--clear` removes all per-key query defaults. Requires a migrated database (`raglogs init`). See [HTTP API authentication](#http-api-authentication) and [per-request overrides](#per-request-query-overrides). @@ -694,11 +694,13 @@ All settings are read from `.env`, environment variables, or CLI flags. Priority | Variable | Default | Description | |---|---|---| | `DB_URL` | `postgresql+psycopg://postgres:postgres@localhost:5432/raglogs` | PostgreSQL connection URL | -| `LLM_PROVIDER` | `disabled` | `disabled`, `openai`, `ollama` | -| `LLM_MODEL` | `gpt-4.1-mini` | LLM model name | +| `LLM_PROVIDER` | `disabled` | `disabled`, `openai`, `ollama`, `claude` | +| `LLM_MODEL` | `gpt-4.1-mini` | LLM model name. Use `claude-haiku-4-5` when `LLM_PROVIDER=claude` | | `OPENAI_API_KEY` | _(empty)_ | API key for OpenAI or compatible endpoint | | `OPENAI_BASE_URL` | `https://api.openai.com/v1` | Base URL for OpenAI-compatible API | | `OLLAMA_BASE_URL` | `http://localhost:11434` | Ollama server URL | +| `ANTHROPIC_API_KEY` | _(empty)_ | API key for Claude (`LLM_PROVIDER=claude`). Empty falls back to noop | +| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | Anthropic Messages API host | | `EMBEDDINGS_PROVIDER` | `disabled` | `disabled`, `openai`, `local`. Cluster merge, semantic `ask`, and `/similar` ANN skip when `disabled` | | `EMBEDDINGS_MODEL` | `text-embedding-3-small` | Embeddings model name | | `EMBEDDINGS_DIMENSIONS` | `1536` | Vector size passed to the OpenAI embeddings API. Persist/ask skip unless this is 1536 (stored column width) | @@ -776,6 +778,16 @@ LLM_MODEL=llama3 OLLAMA_BASE_URL=http://localhost:11434 ``` +### Claude (Anthropic) + +```env +LLM_PROVIDER=claude +LLM_MODEL=claude-haiku-4-5 +ANTHROPIC_API_KEY=sk-ant-... +``` + +Uses the Anthropic Messages API (`POST /v1/messages`) via raw httpx. An empty `ANTHROPIC_API_KEY` falls back to the deterministic template provider. Override `ANTHROPIC_BASE_URL` only for a proxy. The OpenAI default model (`gpt-4.1-mini`) is unchanged when `LLM_PROVIDER=openai`. + ### Any OpenAI-compatible endpoint ```env @@ -1215,10 +1227,10 @@ curl -X POST http://localhost:8000/v1/query/explain \ | `baseline_window` | duration parsed like CLI (`30m`, `24h`, `7d`) | `DEFAULT_BASELINE_WINDOW` (`24h`) | | `max_clusters` | 1–100 | `MAX_CLUSTERS_FOR_EXPLAIN` (`10`) | | `max_evidence_items` | 1–50 | `MAX_EVIDENCE_ITEMS` (`8`) | -| `llm.provider` | `openai` / `ollama` / `disabled` | `LLM_PROVIDER` | +| `llm.provider` | `openai` / `ollama` / `claude` / `disabled` | `LLM_PROVIDER` | | `llm.enabled` | bool; `false` acts like `no_llm` | inferred from `LLM_PROVIDER` | -**Precedence:** request field > per-key default (`api_keys.config_json`) > server env default. Omitted fields fall through. When `AUTH_ENABLED=false` the per-key layer is skipped. `llm.provider` does not persist globally; openai without `OPENAI_API_KEY` still uses the noop provider. The explain cache key includes the **resolved** overrides so different `max_clusters` values do not share an entry. +**Precedence:** request field > per-key default (`api_keys.config_json`) > server env default. Omitted fields fall through. When `AUTH_ENABLED=false` the per-key layer is skipped. `llm.provider` does not persist globally; openai without `OPENAI_API_KEY` and claude without `ANTHROPIC_API_KEY` still use the noop provider. The explain cache key includes the **resolved** overrides so different `max_clusters` values do not share an entry. Invalid values return **400**: diff --git a/docker-compose.yml b/docker-compose.yml index 1befdad..5cb595d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,8 @@ services: LLM_MODEL: ${LLM_MODEL:-gpt-4.1-mini} OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-https://api.anthropic.com} EMBEDDINGS_PROVIDER: ${EMBEDDINGS_PROVIDER:-disabled} API_BIND_HOST: "0.0.0.0" # Compose binds 0.0.0.0 inside the container. Default AUTH_ENABLED=false @@ -56,6 +58,8 @@ services: LLM_MODEL: ${LLM_MODEL:-gpt-4.1-mini} OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-https://api.anthropic.com} EMBEDDINGS_PROVIDER: ${EMBEDDINGS_PROVIDER:-disabled} command: raglogs worker volumes: diff --git a/src/api/overrides.py b/src/api/overrides.py index 32e8fe3..b63c3bc 100644 --- a/src/api/overrides.py +++ b/src/api/overrides.py @@ -7,7 +7,7 @@ - ``max_clusters``: 1–100 - ``max_evidence_items``: 1–50 - ``baseline_window``: duration string parsed by ``parse_duration`` -- ``llm.provider``: ``openai`` | ``ollama`` | ``disabled`` +- ``llm.provider``: ``openai`` | ``ollama`` | ``claude`` | ``disabled`` """ from __future__ import annotations @@ -29,8 +29,8 @@ MAX_EVIDENCE_ITEMS_MIN = 1 MAX_EVIDENCE_ITEMS_MAX = 50 -LLM_PROVIDERS: frozenset[str] = frozenset({"openai", "ollama", "disabled"}) -LlmProviderName = Literal["disabled", "openai", "ollama"] +LLM_PROVIDERS: frozenset[str] = frozenset({"openai", "ollama", "claude", "disabled"}) +LlmProviderName = Literal["disabled", "openai", "ollama", "claude"] class OverrideValidationError(Exception): @@ -74,7 +74,7 @@ class LlmOverride(BaseModel): provider: Optional[str] = Field( default=None, - description="openai | ollama | disabled. Omitted: per-key default, then LLM_PROVIDER.", + description="openai | ollama | claude | disabled. Omitted: per-key default, then LLM_PROVIDER.", ) enabled: Optional[bool] = Field( default=None, diff --git a/src/api/routes/health.py b/src/api/routes/health.py index 3c32a0f..ab502e5 100644 --- a/src/api/routes/health.py +++ b/src/api/routes/health.py @@ -19,7 +19,7 @@ class LlmBreakerHealth(BaseModel): class LlmProviderHealth(BaseModel): - provider: str # disabled | openai | ollama + provider: str # disabled | openai | ollama | claude status: str # ok | disabled | unavailable: @@ -77,7 +77,7 @@ def _llm_breaker_health() -> LlmBreakerHealth: def _llm_provider_health() -> LlmProviderHealth: - """Local-only status — does not ping OpenAI or Ollama.""" + """Local-only status — does not ping OpenAI, Ollama, or Anthropic.""" from src.config import get_settings settings = get_settings() @@ -91,6 +91,13 @@ def _llm_provider_health() -> LlmProviderHealth: status="unavailable: OPENAI_API_KEY is not set", ) return LlmProviderHealth(provider="openai", status="ok") + if provider == "claude": + if not settings.anthropic_api_key: + return LlmProviderHealth( + provider="claude", + status="unavailable: ANTHROPIC_API_KEY is not set", + ) + return LlmProviderHealth(provider="claude", status="ok") if provider == "ollama": if not settings.ollama_base_url: return LlmProviderHealth( diff --git a/src/cli/commands/config_cmd.py b/src/cli/commands/config_cmd.py index 21b13e9..702c327 100644 --- a/src/cli/commands/config_cmd.py +++ b/src/cli/commands/config_cmd.py @@ -28,6 +28,8 @@ def config_cmd( "DB_URL": settings.db_url, "LLM_PROVIDER": settings.llm_provider, "LLM_MODEL": settings.llm_model, + "OPENAI_API_KEY": settings.openai_api_key, + "ANTHROPIC_API_KEY": settings.anthropic_api_key, "EMBEDDINGS_PROVIDER": settings.embeddings_provider, "EMBEDDINGS_MODEL": settings.embeddings_model, "CLUSTER_MERGE_SIMILARITY_THRESHOLD": str( diff --git a/src/cli/commands/keys.py b/src/cli/commands/keys.py index 272852e..14f40d8 100644 --- a/src/cli/commands/keys.py +++ b/src/cli/commands/keys.py @@ -49,7 +49,7 @@ def create_cmd( None, "--baseline-window", help="Per-key default baseline window (e.g. 24h)" ), llm_provider: Optional[str] = typer.Option( - None, "--llm-provider", help="Per-key default LLM provider: openai|ollama|disabled" + None, "--llm-provider", help="Per-key default LLM provider: openai|ollama|claude|disabled" ), llm_enabled: Optional[bool] = typer.Option( None, @@ -196,7 +196,7 @@ def set_defaults_cmd( None, "--baseline-window", help="Per-key default baseline window (e.g. 24h)" ), llm_provider: Optional[str] = typer.Option( - None, "--llm-provider", help="Per-key default LLM provider: openai|ollama|disabled" + None, "--llm-provider", help="Per-key default LLM provider: openai|ollama|claude|disabled" ), llm_enabled: Optional[bool] = typer.Option( None, diff --git a/src/cli/commands/status.py b/src/cli/commands/status.py index e00d70f..ded374a 100644 --- a/src/cli/commands/status.py +++ b/src/cli/commands/status.py @@ -38,9 +38,15 @@ def status_cmd(): table.add_row("Log entries:", f"[red]Error: {e}[/red]") openai_key = settings.openai_api_key + if settings.llm_provider == "openai": + llm_key = openai_key + elif settings.llm_provider == "claude": + llm_key = settings.anthropic_api_key + else: + llm_key = "n/a" table.add_row("", "") - table.add_row("LLM provider:", _provider_status(settings.llm_provider, openai_key if settings.llm_provider == "openai" else "n/a")) + table.add_row("LLM provider:", _provider_status(settings.llm_provider, llm_key)) table.add_row("LLM model:", settings.llm_model) table.add_row("Embeddings:", _provider_status(settings.embeddings_provider, openai_key if settings.embeddings_provider == "openai" else "n/a")) @@ -52,6 +58,6 @@ def status_cmd(): def _provider_status(provider: str, api_key: str) -> str: if provider == "disabled": return "[dim]disabled[/dim]" - if provider == "openai" and not api_key: + if provider in ("openai", "claude") and not api_key: return f"[yellow]{provider} (no API key)[/yellow]" return f"[green]{provider}[/green]" diff --git a/src/config/settings.py b/src/config/settings.py index 4e512ea..ad31f81 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -27,12 +27,14 @@ class Settings(BaseSettings): # Similar-incident search (pgvector over cluster_embeddings; fingerprint fallback) similar_semantic_min_similarity: float = 0.80 - llm_provider: Literal["disabled", "openai", "ollama"] = "disabled" + llm_provider: Literal["disabled", "openai", "ollama", "claude"] = "disabled" llm_model: str = "gpt-4.1-mini" openai_api_key: str = "" openai_base_url: str = "https://api.openai.com/v1" ollama_base_url: str = "http://localhost:11434" + anthropic_api_key: str = "" + anthropic_base_url: str = "https://api.anthropic.com" default_baseline_window: str = "24h" max_evidence_items: int = 8 diff --git a/src/core/llm/provider.py b/src/core/llm/provider.py index ea52440..df659c6 100644 --- a/src/core/llm/provider.py +++ b/src/core/llm/provider.py @@ -127,6 +127,46 @@ def generate_summary(self, evidence_packet: dict) -> str: return self.complete(SYSTEM_PROMPT, user_message) +class ClaudeLLMProvider: + def __init__( + self, + api_key: str, + model: str = "claude-haiku-4-5", + base_url: str = "https://api.anthropic.com", + ): + self.api_key = api_key + self.model = model + self.base_url = base_url.rstrip("/") + + def complete(self, system_prompt: str, user_message: str) -> str: + """Single Anthropic Messages API attempt (timeout/max_tokens from settings).""" + with httpx.Client(timeout=_llm_timeout()) as client: + response = client.post( + f"{self.base_url}/v1/messages", + headers={ + "x-api-key": self.api_key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + json={ + "model": self.model, + "max_tokens": _llm_max_tokens(), + "system": system_prompt, + "messages": [ + {"role": "user", "content": user_message}, + ], + }, + ) + response.raise_for_status() + data = response.json() + return data["content"][0]["text"].strip() + + def generate_summary(self, evidence_packet: dict) -> str: + payload = json.dumps(evidence_packet, default=str, indent=2) + user_message = f"Analyze this incident evidence and produce a summary:\n\n{payload}" + return self.complete(SYSTEM_PROMPT, user_message) + + class ResilientLLMProvider: """G10 breaker + token budget + retries around an inner provider. @@ -204,7 +244,7 @@ class CappedLLMProvider: Noop inner providers skip the wait so deterministic mode never blocks, but still go through this entrypoint (CLI and API share the semaphore). - Stack (outer → inner): CappedLLMProvider → ResilientLLMProvider → OpenAI/Ollama. + Stack (outer → inner): CappedLLMProvider → ResilientLLMProvider → OpenAI/Ollama/Claude. Noop skips ResilientLLMProvider entirely. """ @@ -234,6 +274,14 @@ def _build_inner_llm_provider(settings: Any) -> LLMProvider: model=settings.llm_model, base_url=settings.openai_base_url, ) + if settings.llm_provider == "claude": + if not settings.anthropic_api_key: + return NoopLLMProvider() + return ClaudeLLMProvider( + api_key=settings.anthropic_api_key, + model=settings.llm_model, + base_url=settings.anthropic_base_url, + ) if settings.llm_provider == "ollama": return OllamaLLMProvider( model=settings.llm_model, diff --git a/src/core/llm/resilience.py b/src/core/llm/resilience.py index 00e1ec8..2600af2 100644 --- a/src/core/llm/resilience.py +++ b/src/core/llm/resilience.py @@ -4,7 +4,7 @@ CappedLLMProvider # G9 process-wide in-flight cap ResilientLLMProvider # this module: breaker → token budget → retries - OpenAI / Ollama # single HTTP attempt with LLM_TIMEOUT / LLM_MAX_TOKENS + OpenAI / Ollama / Claude # single HTTP attempt with LLM_TIMEOUT / LLM_MAX_TOKENS Noop is not wrapped, so ``LLM_PROVIDER=disabled`` never trips the breaker. While the breaker is open the provider is not called (no failure increment). diff --git a/src/core/retrieval/question_router.py b/src/core/retrieval/question_router.py index 59e2cd1..0b31cc1 100644 --- a/src/core/retrieval/question_router.py +++ b/src/core/retrieval/question_router.py @@ -433,12 +433,13 @@ def _call_llm_ask( concurrency semaphore with generate_summary. Order matches generate_summary: cap (slot) → breaker/retries (invoke_llm) - → single HTTP attempt on the unwrapped OpenAI/Ollama provider. + → single HTTP attempt on the unwrapped OpenAI/Ollama/Claude provider. """ import json from src.config import get_settings from src.core.llm.provider import ( + ClaudeLLMProvider, NoopLLMProvider, OpenAILLMProvider, OllamaLLMProvider, @@ -458,12 +459,14 @@ def _call_llm_ask( def _attempt() -> str: if isinstance(inner, OpenAILLMProvider): return inner.complete(ASK_SYSTEM_PROMPT, user_message) + if isinstance(inner, ClaudeLLMProvider): + return inner.complete(ASK_SYSTEM_PROMPT, user_message) if isinstance(inner, OllamaLLMProvider): return inner.complete(ASK_SYSTEM_PROMPT, f"{user_message}\n\nAnswer:") return "" with llm_concurrency_slot(skip=isinstance(inner, NoopLLMProvider)): - if isinstance(inner, (OpenAILLMProvider, OllamaLLMProvider)): + if isinstance(inner, (OpenAILLMProvider, OllamaLLMProvider, ClaudeLLMProvider)): return invoke_llm(_attempt) return "" diff --git a/src/observability/logging.py b/src/observability/logging.py index ad6f116..d368c6e 100644 --- a/src/observability/logging.py +++ b/src/observability/logging.py @@ -14,6 +14,7 @@ "authorization", "api_key", "openai_api_key", + "anthropic_api_key", "password", "token", "bearer", diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 2a28745..056154a 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -89,7 +89,7 @@ def test_health_ok_when_db_connected(self): data = resp.json() assert data["status"] == "ok" assert data["db"] == "connected" - assert data["llm"]["provider"] in {"disabled", "openai", "ollama"} + assert data["llm"]["provider"] in {"disabled", "openai", "ollama", "claude"} assert "status" in data["llm"] def test_health_degraded_when_db_disconnected(self): diff --git a/tests/unit/test_llm_provider.py b/tests/unit/test_llm_provider.py new file mode 100644 index 0000000..7269bba --- /dev/null +++ b/tests/unit/test_llm_provider.py @@ -0,0 +1,272 @@ +"""Unit tests for the Claude LLM provider and factory (no database, no live API).""" +from __future__ import annotations + +from collections.abc import Iterator +from datetime import datetime, timezone +from typing import Any +from unittest.mock import MagicMock, patch + +import httpx +import pytest +from tenacity import wait_none + +from src.config.settings import Settings +from src.core.clustering.clusterer import ClusterData +from src.core.explain.confidence import compute_confidence +from src.core.explain.evidence import EvidencePacket +from src.core.explain.summarizer import explain_window +from src.core.explain.templates import render_text_summary +from src.core.llm.provider import ( + SYSTEM_PROMPT, + CappedLLMProvider, + ClaudeLLMProvider, + NoopLLMProvider, + OpenAILLMProvider, + ResilientLLMProvider, + build_llm_provider, + unwrap_llm_provider, +) +from src.core.llm.resilience import reset_llm_breaker +from src.core.retrieval.question_router import ASK_SYSTEM_PROMPT, _call_llm_ask + +WINDOW_START = datetime(2026, 3, 12, 13, 0, tzinfo=timezone.utc) +WINDOW_END = datetime(2026, 3, 12, 14, 0, tzinfo=timezone.utc) + + +def _settings(**kwargs: Any) -> Settings: + return Settings(_env_file=None, **kwargs) + + +def _packet(message: str = "payment gateway 502") -> EvidencePacket: + cluster = ClusterData( + fingerprint="abcd1234", + representative_message=message, + count=50, + services={"checkout": 50}, + levels={"error": 50}, + first_seen=WINDOW_START, + last_seen=WINDOW_END, + baseline_count=0, + change_ratio=51.0, + importance_score=8.0, + ) + return EvidencePacket( + window_start=WINDOW_START, + window_end=WINDOW_END, + total_logs=404, + primary_cluster=cluster, + secondary_clusters=[], + trigger_candidates=[], + evidence_items=[f"184 similar failures: {message}"], + services_affected=["checkout"], + ) + + +class _FakeResponse: + def __init__( + self, + payload: dict[str, Any] | None = None, + *, + status_code: int = 200, + error: Exception | None = None, + ) -> None: + self._payload = payload or {} + self.status_code = status_code + self._error = error + self.request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + + def raise_for_status(self) -> None: + if self._error is not None: + raise self._error + if self.status_code >= 400: + raise httpx.HTTPStatusError( + "error", + request=self.request, + response=httpx.Response(self.status_code, request=self.request), + ) + + def json(self) -> dict[str, Any]: + return self._payload + + +class _CapturingClient: + last_url: str | None = None + last_headers: dict[str, str] | None = None + last_json: dict[str, Any] | None = None + last_timeout: float | None = None + response: _FakeResponse | None = None + + def __init__(self, *args: object, timeout: float | None = None, **kwargs: object) -> None: + type(self).last_timeout = timeout + + def __enter__(self) -> _CapturingClient: + return self + + def __exit__(self, *args: object) -> bool: + return False + + def post(self, url: str, **kwargs: object) -> _FakeResponse: + type(self).last_url = url + headers = kwargs.get("headers") + payload = kwargs.get("json") + type(self).last_headers = headers if isinstance(headers, dict) else None + type(self).last_json = payload if isinstance(payload, dict) else None + if type(self).response is not None: + return type(self).response + return _FakeResponse({"content": [{"type": "text", "text": " claude summary "}]}) + + +@pytest.fixture(autouse=True) +def _reset_breaker() -> Iterator[None]: + reset_llm_breaker() + _CapturingClient.response = None + _CapturingClient.last_url = None + _CapturingClient.last_headers = None + _CapturingClient.last_json = None + yield + reset_llm_breaker() + _CapturingClient.response = None + + +def test_factory_returns_claude_when_configured() -> None: + llm = build_llm_provider( + _settings( + llm_provider="claude", + anthropic_api_key="sk-ant-test", + llm_model="claude-haiku-4-5", + ) + ) + assert isinstance(llm, CappedLLMProvider) + assert isinstance(llm.inner, ResilientLLMProvider) + inner = unwrap_llm_provider(llm) + assert isinstance(inner, ClaudeLLMProvider) + assert inner.model == "claude-haiku-4-5" + assert inner.api_key == "sk-ant-test" + assert inner.base_url == "https://api.anthropic.com" + + +def test_factory_returns_noop_when_claude_key_missing() -> None: + llm = build_llm_provider(_settings(llm_provider="claude", anthropic_api_key="")) + assert isinstance(llm, CappedLLMProvider) + assert not isinstance(llm.inner, ResilientLLMProvider) + assert isinstance(unwrap_llm_provider(llm), NoopLLMProvider) + assert llm.generate_summary({"x": 1}) == "" + + +def test_openai_default_model_unchanged_when_provider_is_openai() -> None: + settings = _settings(llm_provider="openai", openai_api_key="sk-test") + assert settings.llm_model == "gpt-4.1-mini" + inner = unwrap_llm_provider(build_llm_provider(settings)) + assert isinstance(inner, OpenAILLMProvider) + assert inner.model == "gpt-4.1-mini" + assert ClaudeLLMProvider(api_key="sk-ant-test").model == "claude-haiku-4-5" + + +def test_generate_summary_builds_messages_request_and_parses_text() -> None: + settings = _settings(llm_timeout=12.0, llm_max_tokens=80) + provider = ClaudeLLMProvider( + api_key="sk-ant-test", + model="claude-haiku-4-5", + base_url="https://api.anthropic.com", + ) + with patch("src.config.get_settings", return_value=settings), \ + patch("httpx.Client", _CapturingClient): + text = provider.generate_summary({"primary": "payment 502"}) + + assert text == "claude summary" + assert _CapturingClient.last_url == "https://api.anthropic.com/v1/messages" + assert _CapturingClient.last_timeout == 12.0 + headers = _CapturingClient.last_headers + assert headers is not None + assert headers["x-api-key"] == "sk-ant-test" + assert headers["anthropic-version"] == "2023-06-01" + assert headers["content-type"] == "application/json" + assert "Authorization" not in headers + body = _CapturingClient.last_json + assert body is not None + assert body["model"] == "claude-haiku-4-5" + assert body["max_tokens"] == 80 + assert body["system"] == SYSTEM_PROMPT + assert body["messages"] == [ + { + "role": "user", + "content": "Analyze this incident evidence and produce a summary:\n\n" + '{\n "primary": "payment 502"\n}', + } + ] + + +def test_claude_http_error_does_not_crash_explain_pipeline() -> None: + packet = _packet("payment gateway 502") + settings = _settings( + llm_provider="claude", + anthropic_api_key="sk-ant-test", + llm_model="claude-haiku-4-5", + llm_max_retries=0, + ) + _CapturingClient.response = _FakeResponse( + error=httpx.HTTPStatusError( + "500", + request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"), + response=httpx.Response(500), + ) + ) + llm = build_llm_provider(settings) + with patch("src.config.get_settings", return_value=settings), \ + patch("src.core.explain.summarizer.get_settings", return_value=settings), \ + patch("src.core.explain.summarizer.run_clustering", return_value=(None, [packet.primary_cluster])), \ + patch("src.core.explain.summarizer.assemble_evidence", return_value=packet), \ + patch("src.core.explain.summarizer.build_llm_provider", return_value=llm), \ + patch("src.core.llm.resilience.default_llm_wait", return_value=wait_none()), \ + patch("httpx.Client", _CapturingClient): + result = explain_window( + db=MagicMock(), + window_start=packet.window_start, + window_end=packet.window_end, + ) + + expected = render_text_summary(packet, compute_confidence(packet)) + assert result.mode == "rules" + assert result.summary_text == expected + assert "payment gateway 502" in result.summary_text + + +def test_call_llm_ask_claude_posts_messages_api() -> None: + settings = _settings(llm_timeout=9.0, llm_max_tokens=120, llm_max_retries=0) + llm = ClaudeLLMProvider(api_key="sk-ant-ask", model="claude-haiku-4-5") + with patch("src.config.get_settings", return_value=settings), \ + patch("src.core.llm.resilience.default_llm_wait", return_value=wait_none()), \ + patch("httpx.Client", _CapturingClient): + text = _call_llm_ask(llm, "why did checkout fail?", {"clusters": [{"message": "502"}]}) + + assert text == "claude summary" + assert _CapturingClient.last_url == "https://api.anthropic.com/v1/messages" + headers = _CapturingClient.last_headers + assert headers is not None + assert headers["x-api-key"] == "sk-ant-ask" + assert headers["anthropic-version"] == "2023-06-01" + assert headers["content-type"] == "application/json" + body = _CapturingClient.last_json + assert body is not None + assert body["system"] == ASK_SYSTEM_PROMPT + assert body["model"] == "claude-haiku-4-5" + assert body["max_tokens"] == 120 + assert body["messages"][0]["role"] == "user" + assert "why did checkout fail?" in body["messages"][0]["content"] + assert "502" in body["messages"][0]["content"] + + +def test_health_treats_claude_like_openai_for_missing_key() -> None: + from src.api.routes.health import _llm_provider_health + + missing = _settings(llm_provider="claude", anthropic_api_key="") + with patch("src.config.get_settings", return_value=missing): + health = _llm_provider_health() + assert health.provider == "claude" + assert health.status == "unavailable: ANTHROPIC_API_KEY is not set" + + ok = _settings(llm_provider="claude", anthropic_api_key="sk-ant-test") + with patch("src.config.get_settings", return_value=ok): + health = _llm_provider_health() + assert health.provider == "claude" + assert health.status == "ok" diff --git a/tests/unit/test_overrides.py b/tests/unit/test_overrides.py index 167b421..7ad55a6 100644 --- a/tests/unit/test_overrides.py +++ b/tests/unit/test_overrides.py @@ -262,9 +262,14 @@ def test_invalid_baseline_window_raises(self) -> None: def test_unknown_provider_raises(self) -> None: with pytest.raises(OverrideValidationError) as exc: - _resolve(OverrideInput(llm_provider="claude")) + _resolve(OverrideInput(llm_provider="gemini")) assert exc.value.field == "llm.provider" - assert "claude" in exc.value.message + assert "gemini" in exc.value.message + + def test_claude_provider_is_accepted(self) -> None: + resolved = _resolve(OverrideInput(llm_provider="claude")) + assert resolved.llm_provider == "claude" + assert resolved.llm_enabled is True def test_openai_without_key_still_noop(self) -> None: settings = _settings(llm_provider="disabled", openai_api_key="") @@ -276,6 +281,16 @@ def test_openai_without_key_still_noop(self) -> None: provider = unwrap_llm_provider(build_llm_provider(overlaid)) assert isinstance(provider, NoopLLMProvider) + def test_claude_without_key_still_noop(self) -> None: + settings = _settings(llm_provider="disabled", anthropic_api_key="") + resolved = _resolve( + OverrideInput(llm_provider="claude"), + settings=settings, + ) + overlaid = settings.model_copy(update={"llm_provider": resolved.llm_provider}) + provider = unwrap_llm_provider(build_llm_provider(overlaid)) + assert isinstance(provider, NoopLLMProvider) + class TestKeyConfigJson: def test_build_and_merge(self) -> None: @@ -292,7 +307,9 @@ def test_build_rejects_out_of_bounds(self) -> None: with pytest.raises(OverrideValidationError): build_key_config_json(max_clusters=0) with pytest.raises(OverrideValidationError): - build_key_config_json(llm_provider="claude") + build_key_config_json(llm_provider="gemini") + built = build_key_config_json(llm_provider="claude") + assert built == {"llm": {"provider": "claude"}} class TestExplainApiOverrides: @@ -350,13 +367,32 @@ def test_out_of_bounds_returns_typed_400(self) -> None: def test_invalid_provider_returns_typed_400(self) -> None: resp = client.post( "/v1/query/explain", - json={"since": "1h", "llm": {"provider": "claude"}}, + json={"since": "1h", "llm": {"provider": "gemini"}}, ) assert resp.status_code == 400 body = resp.json() assert body["error_code"] == ERROR_INVALID_OVERRIDE assert body["field"] == "llm.provider" + def test_claude_provider_is_accepted_on_explain(self) -> None: + settings = _settings(llm_provider="disabled") + mock_db = _ctx_db() + with patch("src.config.get_settings", return_value=settings), \ + patch("src.db.session.get_db", side_effect=lambda: mock_db), \ + patch( + "src.core.explain.summarizer.explain_window", + return_value=_explain_result(), + ) as mock_explain, \ + patch("src.api.routes.explain._load_from_cache", return_value=None), \ + patch("src.api.routes.explain._save_to_cache"): + resp = client.post( + "/v1/query/explain", + json={"since": "1h", "no_llm": True, "llm": {"provider": "claude"}}, + ) + + assert resp.status_code == 200 + assert mock_explain.call_args.kwargs["llm_provider"] == "claude" + def test_invalid_baseline_returns_typed_400(self) -> None: resp = client.post( "/v1/query/explain", diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 71ee0e1..60cb028 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -66,6 +66,37 @@ def test_ignores_legacy_raglogs_prefix(monkeypatch): ) +def test_anthropic_settings_from_env(monkeypatch): + monkeypatch.setenv("LLM_PROVIDER", "claude") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://proxy.example/anthropic") + monkeypatch.setenv("LLM_MODEL", "claude-haiku-4-5") + + settings = Settings(_env_file=None) + + assert settings.llm_provider == "claude" + assert settings.anthropic_api_key == "sk-ant-test" + assert settings.anthropic_base_url == "https://proxy.example/anthropic" + assert settings.llm_model == "claude-haiku-4-5" + + +def test_anthropic_settings_defaults_and_openai_model_unchanged(): + settings = Settings(_env_file=None) + assert settings.llm_provider == "disabled" + assert settings.llm_model == "gpt-4.1-mini" + assert settings.anthropic_api_key == "" + assert settings.anthropic_base_url == "https://api.anthropic.com" + + +def test_ignores_legacy_raglogs_prefix_for_anthropic(monkeypatch): + monkeypatch.setenv("RAGLOGS_ANTHROPIC_API_KEY", "sk-ant-legacy") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + settings = Settings(_env_file=None) + + assert settings.anthropic_api_key == "" + + def test_auth_settings_defaults_disabled(): settings = Settings(_env_file=None) assert settings.auth_enabled is False From b2771b9b445c083b0a79f112898c41ee49a3a562 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 12:48:46 +0000 Subject: [PATCH 2/2] Fix ruff issues in Claude provider tests and status CLI Drop the unused typer import in status and format the new unit tests so ruff check on touched files stays clean. Co-authored-by: Leonardo --- src/cli/commands/status.py | 1 - tests/unit/test_llm_provider.py | 46 ++++++++++++++++++++++----------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/cli/commands/status.py b/src/cli/commands/status.py index ded374a..87fcd7e 100644 --- a/src/cli/commands/status.py +++ b/src/cli/commands/status.py @@ -1,4 +1,3 @@ -import typer from rich.console import Console from rich.table import Table diff --git a/tests/unit/test_llm_provider.py b/tests/unit/test_llm_provider.py index 7269bba..9a06da4 100644 --- a/tests/unit/test_llm_provider.py +++ b/tests/unit/test_llm_provider.py @@ -1,4 +1,5 @@ """Unit tests for the Claude LLM provider and factory (no database, no live API).""" + from __future__ import annotations from collections.abc import Iterator @@ -96,7 +97,9 @@ class _CapturingClient: last_timeout: float | None = None response: _FakeResponse | None = None - def __init__(self, *args: object, timeout: float | None = None, **kwargs: object) -> None: + def __init__( + self, *args: object, timeout: float | None = None, **kwargs: object + ) -> None: type(self).last_timeout = timeout def __enter__(self) -> _CapturingClient: @@ -113,7 +116,9 @@ def post(self, url: str, **kwargs: object) -> _FakeResponse: type(self).last_json = payload if isinstance(payload, dict) else None if type(self).response is not None: return type(self).response - return _FakeResponse({"content": [{"type": "text", "text": " claude summary "}]}) + return _FakeResponse( + {"content": [{"type": "text", "text": " claude summary "}]} + ) @pytest.fixture(autouse=True) @@ -169,8 +174,10 @@ def test_generate_summary_builds_messages_request_and_parses_text() -> None: model="claude-haiku-4-5", base_url="https://api.anthropic.com", ) - with patch("src.config.get_settings", return_value=settings), \ - patch("httpx.Client", _CapturingClient): + with ( + patch("src.config.get_settings", return_value=settings), + patch("httpx.Client", _CapturingClient), + ): text = provider.generate_summary({"primary": "payment 502"}) assert text == "claude summary" @@ -212,13 +219,18 @@ def test_claude_http_error_does_not_crash_explain_pipeline() -> None: ) ) llm = build_llm_provider(settings) - with patch("src.config.get_settings", return_value=settings), \ - patch("src.core.explain.summarizer.get_settings", return_value=settings), \ - patch("src.core.explain.summarizer.run_clustering", return_value=(None, [packet.primary_cluster])), \ - patch("src.core.explain.summarizer.assemble_evidence", return_value=packet), \ - patch("src.core.explain.summarizer.build_llm_provider", return_value=llm), \ - patch("src.core.llm.resilience.default_llm_wait", return_value=wait_none()), \ - patch("httpx.Client", _CapturingClient): + with ( + patch("src.config.get_settings", return_value=settings), + patch("src.core.explain.summarizer.get_settings", return_value=settings), + patch( + "src.core.explain.summarizer.run_clustering", + return_value=(None, [packet.primary_cluster]), + ), + patch("src.core.explain.summarizer.assemble_evidence", return_value=packet), + patch("src.core.explain.summarizer.build_llm_provider", return_value=llm), + patch("src.core.llm.resilience.default_llm_wait", return_value=wait_none()), + patch("httpx.Client", _CapturingClient), + ): result = explain_window( db=MagicMock(), window_start=packet.window_start, @@ -234,10 +246,14 @@ def test_claude_http_error_does_not_crash_explain_pipeline() -> None: def test_call_llm_ask_claude_posts_messages_api() -> None: settings = _settings(llm_timeout=9.0, llm_max_tokens=120, llm_max_retries=0) llm = ClaudeLLMProvider(api_key="sk-ant-ask", model="claude-haiku-4-5") - with patch("src.config.get_settings", return_value=settings), \ - patch("src.core.llm.resilience.default_llm_wait", return_value=wait_none()), \ - patch("httpx.Client", _CapturingClient): - text = _call_llm_ask(llm, "why did checkout fail?", {"clusters": [{"message": "502"}]}) + with ( + patch("src.config.get_settings", return_value=settings), + patch("src.core.llm.resilience.default_llm_wait", return_value=wait_none()), + patch("httpx.Client", _CapturingClient), + ): + text = _call_llm_ask( + llm, "why did checkout fail?", {"clusters": [{"message": "502"}]} + ) assert text == "claude summary" assert _CapturingClient.last_url == "https://api.anthropic.com/v1/messages"