Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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 /
Expand Down Expand Up @@ -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
Expand Down
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,7 @@ raglogs keys revoke <key-uuid>
| `--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).
Expand All @@ -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) |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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**:

Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions src/api/overrides.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions src/api/routes/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: <reason>


Expand Down Expand Up @@ -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()
Expand All @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions src/cli/commands/config_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions src/cli/commands/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 8 additions & 3 deletions src/cli/commands/status.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import typer
from rich.console import Console
from rich.table import Table

Expand Down Expand Up @@ -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"))

Expand All @@ -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]"
4 changes: 3 additions & 1 deletion src/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 49 additions & 1 deletion src/core/llm/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
"""

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/core/llm/resilience.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
7 changes: 5 additions & 2 deletions src/core/retrieval/question_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 ""
1 change: 1 addition & 0 deletions src/observability/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"authorization",
"api_key",
"openai_api_key",
"anthropic_api_key",
"password",
"token",
"bearer",
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading