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..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 @@ -38,9 +37,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 +57,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..9a06da4 --- /dev/null +++ b/tests/unit/test_llm_provider.py @@ -0,0 +1,288 @@ +"""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