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
38 changes: 24 additions & 14 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,23 @@ JWT_SECRET=dev-only-secret
DATABASE_URL=postgresql+asyncpg://shift_rescue:shift_rescue@localhost:5433/shift_rescue
REDIS_URL=redis://localhost:6379/0

# --- LLM (spec §7.2) ---------------------------------------------------------
LLM_PROVIDER=anthropic # anthropic | bedrock | nan
ANTHROPIC_API_KEY=
LLM_MODEL_INTERPRETER=claude-haiku-4-5-20251001
LLM_MODEL_COMPOSER=claude-sonnet-5
LLM_MODEL_SUMMARIZER=claude-sonnet-5
# Optional per-piece provider override (send only the interpreter to NaN):
# LLM_PROVIDER_INTERPRETER=nan
# NAN_API_KEY=
# NAN_BASE_URL=https://api.nan.builders/v1
# LLM_MODEL_INTERPRETER_NAN=nan/deepseek-v4-flash
# --- LLM (spec §6, ADR-004) --------------------------------------------------
# Fail-closed: provider "none", a missing key or a missing provider SDK makes
# the API answer with the deterministic parser and log `llm_disabled` — it
# never blocks a rescue. A stale provider variable is ignored (extra="ignore").
LLM_PROVIDER=openai # openai | anthropic | bedrock | none
OPENAI_API_KEY=... # required for provider=openai
# OPENAI_BASE_URL= # any OpenAI-compatible gateway (e.g. NaN)
ANTHROPIC_API_KEY=... # only for provider=anthropic
AWS_REGION=eu-west-1 # only for provider=bedrock (instance role)
LLM_MODEL_INTERPRETER= # empty = provider default (gpt-4o-mini)
LLM_TEMPERATURE=0.0
LLM_MAX_TOKENS=500
LLM_TIMEOUT_SECONDS=10
LLM_CONFIDENCE_THRESHOLD=0.75
# Optional cost overrides, USD per 1K tokens (0 = provider default):
# LLM_PRICE_INPUT_PER_1K=0.00015
# LLM_PRICE_OUTPUT_PER_1K=0.0006

# --- Twilio WhatsApp (docs/twilio-sandbox-setup.md) --------------------------
TWILIO_ACCOUNT_SID=
Expand All @@ -28,10 +34,14 @@ TWILIO_WHATSAPP_FROM=whatsapp:+14155238886
TWILIO_VALIDATE_SIGNATURE=true

# --- Observability (Langfuse Cloud — no self-hosting, see docs/assumptions.md A4)
OTEL_EXPORTER_OTLP_ENDPOINT=https://cloud.langfuse.com/api/public/otel/v1/traces
LANGFUSE_PUBLIC_KEY=
LANGFUSE_SECRET_KEY=
# Both keys present -> OTLP/HTTP spans to
# <LANGFUSE_HOST>/api/public/otel/v1/traces with Basic auth. Keys absent ->
# tracing is a no-op and the API logs `tracing_disabled`.
LANGFUSE_PUBLIC_KEY=...
LANGFUSE_SECRET_KEY=...
LANGFUSE_HOST=https://cloud.langfuse.com
# Explicit override for any OTLP collector (wins over the derived Langfuse URL):
# OTEL_EXPORTER_OTLP_ENDPOINT=
SENTRY_DSN=

# --- Demo / development ------------------------------------------------------
Expand Down
172 changes: 172 additions & 0 deletions backend/app/agent/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"""LLM provider factory (ADR-004): builds the Strands model and the
`MessageInterpreter` from `Settings`, fail-closed.

Provider SDKs are imported lazily inside the functions, so the API boots
without any provider package installed. `build_interpreter()` never raises on
the API path: when the provider is disabled, a credential is missing or the
SDK is absent it returns `None` and the orchestrator degrades to the
deterministic parser (spec §9.3). Strands is imported only here and in
`app.agent.llm` (ADR-002 boundary); the agent runs WITHOUT tools — the LLM
interprets language, it never mutates state.
"""

from pathlib import Path
from typing import Any

import structlog

from app.agent.interpreter import MessageInterpreter
from app.core.config import Settings

logger = structlog.get_logger(__name__)

# USD per 1K tokens (provider defaults; overridable via Settings).
PROVIDER_DEFAULT_MODELS: dict[str, str] = {
"openai": "gpt-4o-mini",
"anthropic": "claude-haiku-4-5",
"bedrock": "eu.anthropic.claude-haiku-4-5-v1:0",
}

PROVIDER_DEFAULT_PRICES: dict[str, dict[str, float]] = {
"openai": {"input": 0.00015, "output": 0.0006},
"anthropic": {"input": 0.0008, "output": 0.004},
"bedrock": {"input": 0.0008, "output": 0.004},
}


class LLMNotConfiguredError(Exception):
"""Raised when the provider cannot be built (missing credential, unknown
provider). The message names the missing environment variable."""


def _provider(settings: Settings) -> str:
return settings.llm_provider.strip().lower()


def resolve_model_id(settings: Settings) -> str:
"""Configured model id, or the provider default for empty/unknown values."""
if settings.llm_model_interpreter:
return settings.llm_model_interpreter
return PROVIDER_DEFAULT_MODELS.get(_provider(settings), PROVIDER_DEFAULT_MODELS["openai"])


def resolve_price(settings: Settings) -> dict[str, float]:
"""Per-1K USD prices: provider default, overridable per direction when > 0."""
price = dict(
PROVIDER_DEFAULT_PRICES.get(_provider(settings), PROVIDER_DEFAULT_PRICES["openai"])
)
if settings.llm_price_input_per_1k > 0.0:
price["input"] = settings.llm_price_input_per_1k
if settings.llm_price_output_per_1k > 0.0:
price["output"] = settings.llm_price_output_per_1k
return price


def _build_openai_model(settings: Settings, model_id: str) -> Any:
from strands.models.openai import OpenAIModel

if not settings.openai_api_key:
raise LLMNotConfiguredError("OPENAI_API_KEY is not set — add it to backend/.env")
client_args: dict[str, str] = {"api_key": settings.openai_api_key}
if settings.openai_base_url:
client_args["base_url"] = settings.openai_base_url
return OpenAIModel(
model_id=model_id,
params={"max_tokens": settings.llm_max_tokens, "temperature": settings.llm_temperature},
client_args=client_args,
)


def _build_anthropic_model(settings: Settings, model_id: str) -> Any:
# Signature verified for strands 1.56.0: AnthropicModel takes client_args
# (it builds its own AsyncAnthropic client); max_tokens/model_id are
# required config keys, temperature rides in params.
from strands.models.anthropic import AnthropicModel

if not settings.anthropic_api_key:
raise LLMNotConfiguredError("ANTHROPIC_API_KEY is not set — add it to backend/.env")
return AnthropicModel(
model_id=model_id,
max_tokens=settings.llm_max_tokens,
params={"temperature": settings.llm_temperature},
client_args={"api_key": settings.anthropic_api_key},
)


def _build_bedrock_model(settings: Settings, model_id: str) -> Any:
# Signature verified (strands 1.56.0): BedrockModel takes keyword-only
# region_name plus flat model_config keys (model_id, temperature, max_tokens).
# Credentials come from the instance role; never exercised in unit tests.
from strands.models.bedrock import BedrockModel

return BedrockModel(
model_id=model_id,
temperature=settings.llm_temperature,
max_tokens=settings.llm_max_tokens,
region_name=settings.aws_region,
)


def build_model(settings: Settings) -> Any:
"""Build the Strands model for the configured provider."""
provider = _provider(settings)
model_id = resolve_model_id(settings)
builders = {
"openai": _build_openai_model,
"anthropic": _build_anthropic_model,
"bedrock": _build_bedrock_model,
}
builder = builders.get(provider)
if builder is None:
raise LLMNotConfiguredError(
f"Unknown llm_provider {provider!r} — expected: {', '.join(sorted(builders))}, none"
)
return builder(settings, model_id)


def load_system_prompt() -> str:
"""Interpreter prompt (baked into the image with the app package)."""
return (Path(__file__).parent / "prompts" / "interpreter_v1.md").read_text(encoding="utf-8")


def build_interpreter(settings: Settings) -> MessageInterpreter | None:
"""Fail-closed entry point: `MessageInterpreter` or `None`.

Never raises on the API path; never logs or returns a credential. A `None`
result means the orchestrator answers with the deterministic parser.
"""
if not settings.llm_enabled:
logger.warning("llm_disabled", reason="provider is disabled")
return None
try:
model = build_model(settings)
except LLMNotConfiguredError as error:
logger.warning("llm_disabled", reason=str(error))
return None
except (ImportError, ModuleNotFoundError):
logger.warning("llm_disabled", reason="provider SDK is not installed")
return None

from strands import Agent

from app.agent.llm import StrandsLLMClient
from app.agent.schemas import Interpretation

model_id = resolve_model_id(settings)
client = StrandsLLMClient(
agent_factory=lambda: Agent(
model=model,
system_prompt=load_system_prompt(),
structured_output_model=Interpretation,
callback_handler=None,
),
timeout_seconds=settings.llm_timeout_seconds,
model_id=model_id,
price_per_1k=resolve_price(settings),
)
return MessageInterpreter(llm=client, confidence_threshold=settings.llm_confidence_threshold)


def describe_provider(settings: Settings) -> str:
"""One-line, secret-free provider/model description for structured logs."""
return f"provider={_provider(settings)} model={resolve_model_id(settings)}"
7 changes: 4 additions & 3 deletions backend/app/agent/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ async def interpret(self, message_body: str, context: dict[str, Any]) -> Interpr
attempt_context = {**attempt_context, "validation_error": last_error}
try:
raw = await self._llm.interpret(message_body, attempt_context)
return Interpretation(
**raw, prompt_version=self.prompt_version
).model_copy()
# A real LLMClient returns the whole structured payload, which
# already carries prompt_version; the interpreter owns it, so it
# must overwrite rather than duplicate the keyword argument.
return Interpretation(**{**raw, "prompt_version": self.prompt_version})
except ValidationError as error:
last_error = str(error)
except Exception as error:
Expand Down
47 changes: 38 additions & 9 deletions backend/app/agent/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"""

import asyncio
import time
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from typing import Any
Expand Down Expand Up @@ -86,6 +87,7 @@ async def interpret(self, message_body: str, context: dict[str, Any]) -> dict[st
prompt = self._build_prompt(message_body, context)
agent = self._agent_factory()

started = time.perf_counter()
result: Any = None
last_error: Exception | None = None
for _attempt in range(self._retries + 1):
Expand All @@ -100,10 +102,11 @@ async def interpret(self, message_body: str, context: dict[str, Any]) -> dict[st
self.breaker.record_failure(now)
raise last_error if last_error else RuntimeError("LLM invocation failed")

elapsed_ms = (time.perf_counter() - started) * 1000
self.breaker.record_success()
return self._extract(result)
return self._extract(result, elapsed_ms)

def _extract(self, result: Any) -> dict[str, Any]:
def _extract(self, result: Any, elapsed_ms: float) -> dict[str, Any]:
structured = getattr(result, "structured_output", None)
if structured is None:
raise ValueError("Agent returned no structured output")
Expand All @@ -113,15 +116,40 @@ def _extract(self, result: Any) -> dict[str, Any]:
payload = Interpretation(**structured).model_dump()
else:
raise ValueError("Unexpected structured output type")
self.last_usage = self._usage_of(result)
self.last_usage = self._usage_of(result, elapsed_ms)
return payload

def _usage_of(self, result: Any) -> dict[str, Any]:
def _usage_of(self, result: Any, elapsed_ms: float) -> dict[str, Any]:
"""Token/latency/cost metering (spec §9.2).

Strands 1.56 reports `EventLoopMetrics.accumulated_usage` with
camelCase keys and `accumulated_metrics['latencyMs']`; the snake_case
names of earlier versions are still accepted. Wall-clock time is the
fallback because the SDK reports 0 for latency in some releases.
"""
metrics = getattr(result, "metrics", None)
raw_usage = getattr(metrics, "usage", None) if metrics is not None else None
input_tokens = float(getattr(raw_usage, "input_tokens", 0) or 0)
output_tokens = float(getattr(raw_usage, "output_tokens", 0) or 0)
latency = float(getattr(metrics, "total_cycle_time", 0) or 0)
raw_usage = (
getattr(metrics, "accumulated_usage", None)
or getattr(metrics, "usage", None)
or {}
)

def token(*names: str) -> float:
for name in names:
value = raw_usage.get(name) if isinstance(raw_usage, dict) else None
if value:
return float(value)
return 0.0

input_tokens = token("inputTokens", "input_tokens")
output_tokens = token("outputTokens", "output_tokens")
cached_input_tokens = token("cacheReadInputTokens", "cache_read_input_tokens")
provider_latency = 0.0
accumulated = getattr(metrics, "accumulated_metrics", None)
if isinstance(accumulated, dict):
provider_latency = float(accumulated.get("latencyMs", 0) or 0)
# Cached reads are billed at a discount by the provider but counted at
# full price here: the number is a conservative upper bound.
cost = (
input_tokens / 1000 * self._price["input"]
+ output_tokens / 1000 * self._price["output"]
Expand All @@ -130,7 +158,8 @@ def _usage_of(self, result: Any) -> dict[str, Any]:
"model": self._model_id,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"latency_ms": latency,
"cached_input_tokens": cached_input_tokens,
"latency_ms": provider_latency or round(elapsed_ms, 1),
"cost_usd": cost,
}

Expand Down
8 changes: 8 additions & 0 deletions backend/app/api/webhooks_twilio.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

from app.agent.factory import build_interpreter, describe_provider
from app.channels.twilio_whatsapp import validate_twilio_signature
from app.core.config import get_settings
from app.observability.redaction import mask_phone
Expand Down Expand Up @@ -117,16 +118,23 @@ def get_twilio_service() -> TwilioInboundService:
from_number=settings.twilio_whatsapp_from,
)
scheduler = SimScheduler()
interpreter = build_interpreter(settings)
orchestrator = RescueOrchestrator(
session_factory=session_factory,
workforce=MockWorkforceAdapter(session_factory),
channel=channel,
scheduler=scheduler,
clock=__import__("app.core.clock", fromlist=["SystemClock"]).SystemClock(),
interpreter=interpreter,
)
for name, handler in orchestrator.task_handlers().items():
scheduler.register(name, handler)
_service = TwilioInboundService(session_factory, orchestrator, scheduler)
structlog.get_logger(__name__).info(
"llm_path",
active=interpreter is not None,
detail=describe_provider(settings),
)
return _service


Expand Down
Loading
Loading