lexora / apps /api /app /core /claude.py
Abdr007's picture
Lexora — deployed tree
3fc8e60
Raw
History Blame Contribute Delete
7.77 kB
"""Anthropic access, with an honest offline fallback.
Model routing, as the spec calls for: Haiku 4.5 does the cheap structural work (rewriting
a follow-up into a standalone query, screening it for injection) and Sonnet 4.6 writes the
grounded answer. Both are traced.
The offline mode
----------------
Lexora is built to run with no ``ANTHROPIC_API_KEY`` at all. In that mode the guard falls
back to a deterministic rewriter/screener and generation to an extractive composer that
quotes retrieved articles verbatim with their citations. This is **not** a simulation of
Claude and is never presented as one: every response carries ``engine="offline-extractive"``
through the API into the UI badge, and the eval harness refuses to report LLM-judged
metrics for it.
What that buys is real: retrieval, fusion, reranking, the refusal gate, the citation
verifier and the whole HTTP surface are exercised and measurable without a key, so the
numbers that do not depend on a language model are trustworthy today and the ones that do
are clearly marked as pending.
"""
from __future__ import annotations
import logging
import threading
from collections.abc import Iterator, Sequence
from dataclasses import dataclass
from typing import Any, Final, Literal
from app.core.observability import trace_generation
from app.core.settings import LLMMode, Settings, get_settings
logger = logging.getLogger(__name__)
_LOCK = threading.Lock()
_CLIENT: Any | None = None
Engine = Literal["anthropic", "offline-extractive"]
# Published per-million-token prices for the routed models, used only to show the user
# what a query cost. Kept together so a price change is a one-line edit.
_PRICING_USD_PER_MTOK: Final[dict[str, tuple[float, float]]] = {
"claude-sonnet-4-6": (3.00, 15.00),
"claude-haiku-4-5-20251001": (1.00, 5.00),
}
_DEFAULT_PRICING: Final[tuple[float, float]] = (3.00, 15.00)
class AnthropicUnavailableError(RuntimeError):
"""``llm_mode=anthropic`` was requested but the API cannot be used."""
@dataclass(frozen=True, slots=True)
class Message:
role: Literal["user", "assistant"]
content: str
@dataclass(frozen=True, slots=True)
class Completion:
text: str
input_tokens: int = 0
output_tokens: int = 0
model: str = ""
engine: Engine = "offline-extractive"
@property
def usd(self) -> float:
return estimate_cost_usd(self.model, self.input_tokens, self.output_tokens)
def estimate_cost_usd(model: str, input_tokens: int, output_tokens: int) -> float:
prompt_price, completion_price = _PRICING_USD_PER_MTOK.get(model, _DEFAULT_PRICING)
return (input_tokens * prompt_price + output_tokens * completion_price) / 1_000_000
def get_client(settings: Settings | None = None) -> Any | None:
"""Anthropic client, or ``None`` when running offline."""
global _CLIENT # noqa: PLW0603 - deliberate process-wide singleton
cfg = settings or get_settings()
if not cfg.use_anthropic:
return None
if _CLIENT is not None:
return _CLIENT
with _LOCK:
if _CLIENT is None:
try:
from anthropic import Anthropic
except ImportError as exc: # pragma: no cover - dependency is declared
raise AnthropicUnavailableError("the `anthropic` package is not installed") from exc
if not cfg.anthropic_api_key:
if cfg.llm_mode is LLMMode.ANTHROPIC:
raise AnthropicUnavailableError(
"LEXORA_LLM_MODE=anthropic requires LEXORA_ANTHROPIC_API_KEY to be set"
)
return None
_CLIENT = Anthropic(api_key=cfg.anthropic_api_key, timeout=cfg.llm_timeout_s)
logger.info("Anthropic client ready")
return _CLIENT
def is_online(settings: Settings | None = None) -> bool:
"""True when real model calls will be made."""
return get_client(settings) is not None
def engine_name(settings: Settings | None = None) -> Engine:
return "anthropic" if is_online(settings) else "offline-extractive"
def complete(
*,
system: str,
messages: Sequence[Message],
model: str,
max_tokens: int,
settings: Settings | None = None,
trace_name: str = "generation",
metadata: dict[str, Any] | None = None,
) -> Completion:
"""One non-streaming model call. Raises only when the caller must know."""
cfg = settings or get_settings()
client = get_client(cfg)
if client is None:
raise AnthropicUnavailableError("no Anthropic client; caller must use its offline path")
payload = [{"role": message.role, "content": message.content} for message in messages]
with trace_generation(
trace_name,
model=model,
input={"system": system, "messages": payload},
metadata=metadata or {},
) as span:
response = client.messages.create(
model=model,
max_tokens=max_tokens,
temperature=cfg.temperature,
system=system,
messages=payload,
)
text = "".join(block.text for block in response.content if block.type == "text")
usage = getattr(response, "usage", None)
completion = Completion(
text=text,
input_tokens=int(getattr(usage, "input_tokens", 0) or 0),
output_tokens=int(getattr(usage, "output_tokens", 0) or 0),
model=model,
engine="anthropic",
)
span.update(
output=text,
usage_details={
"input": completion.input_tokens,
"output": completion.output_tokens,
},
)
return completion
def stream(
*,
system: str,
messages: Sequence[Message],
model: str,
max_tokens: int,
settings: Settings | None = None,
trace_name: str = "generation",
metadata: dict[str, Any] | None = None,
) -> Iterator[str | Completion]:
"""Stream text deltas, then yield a final :class:`Completion` with usage.
The mixed yield type keeps the caller from having to reconcile a stream with a
separate usage lookup: the last item is always the completion record.
"""
cfg = settings or get_settings()
client = get_client(cfg)
if client is None:
raise AnthropicUnavailableError("no Anthropic client; caller must use its offline path")
payload = [{"role": message.role, "content": message.content} for message in messages]
chunks: list[str] = []
with trace_generation(
trace_name,
model=model,
input={"system": system, "messages": payload},
metadata=metadata or {},
) as span:
with client.messages.stream(
model=model,
max_tokens=max_tokens,
temperature=cfg.temperature,
system=system,
messages=payload,
) as streamed:
for delta in streamed.text_stream:
chunks.append(delta)
yield delta
final = streamed.get_final_message()
usage = getattr(final, "usage", None)
completion = Completion(
text="".join(chunks),
input_tokens=int(getattr(usage, "input_tokens", 0) or 0),
output_tokens=int(getattr(usage, "output_tokens", 0) or 0),
model=model,
engine="anthropic",
)
span.update(
output=completion.text,
usage_details={
"input": completion.input_tokens,
"output": completion.output_tokens,
},
)
yield completion
def reset_client() -> None:
"""Drop the cached client. Used by tests."""
global _CLIENT # noqa: PLW0603 - mirrors get_client
with _LOCK:
_CLIENT = None