| """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"] |
|
|
| |
| |
| _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 |
| 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: |
| 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 |
| with _LOCK: |
| _CLIENT = None |
|
|