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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ apps/stacker SvelteKit portal: SSO, subscription gating, module registry
services/petdata Animal data: connectors, canonical Animal Record, Package builder
services/biowriter Generation: kennel cards, social posts, the lint/score editor (not yet scaffolded)
services/retriever RAG: shelter-ops chat, and the research-corpus index for citations
packages/ Shared packages: auth, schema, design-system (built; see each package's own README)
packages/ Shared packages: auth, schema, design-system, llm (built; see each package's own README)
docs/ Architecture, ADRs, and the research corpus
tools/ Local dev tooling (the offline LLM stub)
scripts/ Repo-level scripts (e.g. the doc link checker)
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0028-llm-gateway-consolidation.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,4 @@ Neither replaces the other. The gateway sees requests and responses at the wire;

## Follow-ups

- Promote `build_gateway_client` into a shared Python package once one exists, so petdata and biowriter import it rather than copying it.
- **Done (#93).** `build_gateway_client` moved into the shared `packages/llm` package (import `evermore_llm`); retriever imports it from there. petdata and biowriter will import it from the same package once they start making gateway calls, rather than copying it.
2 changes: 1 addition & 1 deletion docs/security/coverage-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ Ticket estimated 160 tracked files under `services/retriever`; the live count at
| `services/retriever/src/retriever/infrastructure/llm/__init__.py` | inert | secret-scan + claim-check | clean -- secret-scan clean; empty or re-export-only package init |
| `services/retriever/src/retriever/infrastructure/llm/exceptions.py` | security-critical | line-by-line | clean -- typed exception classes only, no logic |
| `services/retriever/src/retriever/infrastructure/llm/fallback.py` | security-critical | line-by-line | clean -- circuit-breaker/retry wrapper around the primary provider, no auth or data-handling logic |
| `services/retriever/src/retriever/infrastructure/llm/gateway_client.py` | security-critical | line-by-line | See #228 (LLM-abuse handoff, #228) -- single static gateway bearer token, no per-scope rotation |
| `packages/llm/src/evermore_llm/gateway_client.py` | security-critical | line-by-line | See #228 (LLM-abuse handoff, #228) -- single static gateway bearer token, no per-scope rotation |
| `services/retriever/src/retriever/infrastructure/llm/openai_compat.py` | security-critical | line-by-line | clean -- OpenAI-compat chat client using the injected gateway client; no hardcoded credentials |
| `services/retriever/src/retriever/infrastructure/llm/protocol.py` | security-critical | line-by-line | clean -- Protocol interface definition only, no logic |
| `services/retriever/src/retriever/infrastructure/observability/__init__.py` | inert | secret-scan + claim-check | clean -- secret-scan clean; empty or re-export-only package init |
Expand Down
2 changes: 1 addition & 1 deletion docs/security/llm-abuse-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Where the services make outbound requests.

| Call site (file:line) | Destination trust | SSRF / egress verdict + severity |
|---|---|---|
| Retriever -> LLM gateway, all model calls (`infrastructure/llm/gateway_client.py:52-57`; `openai_compat.py:172,326`; `infrastructure/embeddings/openai.py`; `infrastructure/safety/moderation.py:69`) | `settings.llm_gateway_base_url` - config-pinned Cloudflare gateway (`config.py:187-214`); **not** request-controlled | No SSRF: URL derives from config, never request input. Single known host. **Low / clean** |
| Retriever -> LLM gateway, all model calls (`packages/llm/src/evermore_llm/gateway_client.py:87-99`; `openai_compat.py:172,326`; `infrastructure/embeddings/openai.py`; `infrastructure/safety/moderation.py:69`) | `settings.llm_gateway_base_url` - config-pinned Cloudflare gateway (`config.py:187-214`); **not** request-controlled | No SSRF: URL derives from config, never request input. Single known host. **Low / clean** |
| Retriever document ingest (`rag/loader.py`, `rag/docling_processor.py`) | processes uploaded bytes; no URL fetch | No fetch-by-URL surface. **Clean** |
| Retriever R2 storage (`infrastructure/storage/r2.py`) | dead code, not wired into any live handler | No live egress. **Clean (dead code)** |
| petdata -> SMS host (`services/petdata/src/petdata/modules/api/client.py:50-56`) | `httpx.Client(follow_redirects=True, headers=<static Cookie>)` - SMS base URL config-pinned, but redirects follow to arbitrary hosts and resend the session cookie | SSRF/egress + credential leak on redirect; relay risk. Already **#245** (redirect+cookie) and **#248** (no size cap). **High** - reconciled, not re-filed |
Expand Down
43 changes: 43 additions & 0 deletions packages/llm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# evermore-llm

Shared model-call transport infrastructure for the Evermore services: one
`build_gateway_client` helper that builds a single `AsyncOpenAI` client
pointed at the OpenAI-compatible LLM gateway (chat, embeddings, and
moderation all go through it). This executes the follow-up documented in
[`docs/adr/0028-llm-gateway-consolidation.md`](../../docs/adr/0028-llm-gateway-consolidation.md):
"Promote `build_gateway_client` into a shared Python package once one exists,
so petdata and biowriter import it rather than copying it." retriever imports
it from here today; petdata and biowriter will import it from the same
package once they start making gateway calls, rather than copying it.

## Scope boundary

This package holds shared transport plumbing only:

- `evermore_llm.gateway_client` provides `build_gateway_client`, the
`GatewaySettings` structural `Protocol` it reads, and the `GatewayScope`
traffic-class literal ("chat", "embeddings", "moderation").

It does **not** hold service-specific wiring (which model to call, how a
service's settings resolve their own fields) or domain contracts (those
belong in [`packages/schema`](../schema/README.md)). Each service keeps its
own `Settings` class; it only needs to expose the four members
`GatewaySettings` names to be accepted by `build_gateway_client`.

## Usage

```python
from evermore_llm import build_gateway_client

client = build_gateway_client(settings, scope="chat")
```

`settings` is any object exposing `llm_gateway_token`, `llm_gateway_auth_header`,
a `llm_gateway_base_url` property, and a `gateway_token_for(scope)` method
(structural typing via `GatewaySettings`; no inheritance required).

## Deviations from repo defaults

Pinned to Python 3.13+, not the repo's 3.14 floor: it matches its current
consumer, `services/retriever`, the same rationale
[`packages/auth`](../auth/README.md) documents.
85 changes: 85 additions & 0 deletions packages/llm/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
[project]
name = "evermore-llm"
version = "0.1.0"
description = "Shared LLM gateway client builder for Evermore"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.13"
dependencies = [
"openai>=1.60",
"pydantic>=2.9",
]

[project.optional-dependencies]
dev = [
"pytest>=8.0",
"mypy>=1.13",
"ruff>=0.8",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/evermore_llm"]

[tool.ruff]
target-version = "py313"
line-length = 88
src = ["src", "tests"]

[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"ARG", # flake8-unused-arguments
"SIM", # flake8-simplify
"TCH", # flake8-type-checking
"PTH", # flake8-use-pathlib
"ERA", # eradicate
"PL", # Pylint
"RUF", # Ruff-specific
]
ignore = [
"PLR0913", # Too many arguments
"PLR2004", # Magic value comparison
]

[tool.ruff.lint.isort]
known-first-party = ["evermore_llm"]

[tool.mypy]
python_version = "3.13"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
follow_imports = "normal"
show_error_codes = true

[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src", "."]
addopts = [
"-ra",
"-q",
"--strict-markers",
"--strict-config",
]
23 changes: 23 additions & 0 deletions packages/llm/src/evermore_llm/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copyright (C) 2025 Backchain LLC
# SPDX-License-Identifier: Apache-2.0

"""Shared LLM gateway transport for Evermore: one AsyncOpenAI client builder.

This package is the single canonical source for :func:`build_gateway_client`,
which builds one ``AsyncOpenAI`` client pointed at the OpenAI-compatible LLM
gateway (see ADR 0028), plus the :class:`GatewaySettings` structural
:class:`~typing.Protocol` it reads and the :data:`GatewayScope` traffic-class
literal.
"""

from evermore_llm.gateway_client import (
GatewayScope,
GatewaySettings,
build_gateway_client,
)

__all__ = [
"GatewayScope",
"GatewaySettings",
"build_gateway_client",
]
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,50 @@
so this builder carries no gateway-specific identifiers. An optional ``scope``
narrows the token used to one traffic class (chat, embeddings, moderation),
shrinking the blast radius of a single leaked token; see
``Settings.gateway_token_for``.
``GatewaySettings.gateway_token_for``.

This module imports nothing from any Evermore service. ``GatewaySettings`` is a
structural :class:`typing.Protocol`: any settings object exposing its four
members (``llm_gateway_token``, ``llm_gateway_auth_header``,
``llm_gateway_base_url``, ``gateway_token_for``) satisfies it without
inheriting from anything defined here.
"""

from __future__ import annotations

from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal, Protocol

from openai import AsyncOpenAI

if TYPE_CHECKING:
from retriever.config import GatewayScope, Settings
from pydantic import SecretStr

# Per-traffic-class gateway token scope. Narrows the blast radius of a
# leaked token to one model traffic class (chat, embeddings, moderation)
# instead of all gateway traffic authenticated by the shared token.
GatewayScope = Literal["chat", "embeddings", "moderation"]


class GatewaySettings(Protocol):
"""Structural interface :func:`build_gateway_client` reads from settings.

A service's own settings type (for example ``retriever.config.Settings``)
implements this Protocol simply by exposing these members; no explicit
inheritance, registration, or import of this package's types is required
on the settings side.
"""

llm_gateway_token: SecretStr
llm_gateway_auth_header: str

@property
def llm_gateway_base_url(self) -> str: ...

def gateway_token_for(self, scope: GatewayScope | None) -> SecretStr: ...


def build_gateway_client(
settings: Settings,
settings: GatewaySettings,
*,
scope: GatewayScope | None = None,
timeout_seconds: float = 30.0,
Expand Down
Empty file.
118 changes: 118 additions & 0 deletions packages/llm/tests/test_gateway_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Tests for the shared LLM gateway client builder (pure, no service Settings).

These tests exercise :func:`build_gateway_client` against a minimal
``GatewaySettings``-conforming stub, never a real service ``Settings`` class
(that would import a service into this package, violating ADR 0001). The
scoped-token tests that need real ``retriever.config.Settings`` stay in
``services/retriever/tests/test_gateway_client.py``.
"""

from __future__ import annotations

from dataclasses import dataclass

from openai import AsyncOpenAI
from pydantic import SecretStr

from evermore_llm import GatewayScope, build_gateway_client


@dataclass
class _StubGatewaySettings:
"""Minimal object structurally satisfying ``GatewaySettings``."""

llm_gateway_token: SecretStr
base_url: str
llm_gateway_auth_header: str = "cf-aig-authorization"

@property
def llm_gateway_base_url(self) -> str:
return self.base_url

def gateway_token_for(self, scope: GatewayScope | None) -> SecretStr: # noqa: ARG002
return self.llm_gateway_token


def _make_settings(
*, base_url: str, token: str, auth_header: str = "cf-aig-authorization"
) -> _StubGatewaySettings:
"""Build a stub settings object exposing the gateway fields the helper reads."""
return _StubGatewaySettings(
llm_gateway_token=SecretStr(token),
base_url=base_url,
llm_gateway_auth_header=auth_header,
)


def test_build_gateway_client_sets_base_url() -> None:
"""Client uses the gateway base URL from settings."""
settings = _make_settings(
base_url="https://gateway.ai.cloudflare.com/v1/a/b/compat",
token="",
)

client = build_gateway_client(settings)

assert isinstance(client, AsyncOpenAI)
assert str(client.base_url).rstrip("/").endswith("/compat")


def test_build_gateway_client_adds_auth_header_when_token_present() -> None:
"""A non-empty gateway token sets the configured auth header."""
settings = _make_settings(
base_url="https://gateway.ai.cloudflare.com/v1/a/b/compat",
token="cf-token-123",
)

client = build_gateway_client(settings)

assert client.default_headers["cf-aig-authorization"] == "Bearer cf-token-123"


def test_build_gateway_client_uses_configured_header_name() -> None:
"""The auth header name comes from settings, not a hardcoded literal."""
settings = _make_settings(
base_url="https://my-gateway.example.com/v1",
token="tok-456",
auth_header="authorization",
)

client = build_gateway_client(settings)

assert client.default_headers["authorization"] == "Bearer tok-456"


def test_build_gateway_client_omits_auth_header_when_token_absent() -> None:
"""An empty gateway token leaves the cf-aig-authorization header unset."""
settings = _make_settings(
base_url="https://gateway.ai.cloudflare.com/v1/a/b/compat",
token="",
)

client = build_gateway_client(settings)

assert "cf-aig-authorization" not in client.default_headers


def test_build_gateway_client_uses_placeholder_api_key_when_token_empty() -> None:
"""The SDK requires a non-empty api_key; an empty token yields a placeholder."""
settings = _make_settings(
base_url="https://gateway.ai.cloudflare.com/v1/a/b/compat",
token="",
)

client = build_gateway_client(settings)

assert client.api_key == "unused"


def test_build_gateway_client_uses_token_as_api_key_when_present() -> None:
"""When a gateway token is set, it doubles as the SDK api_key value."""
settings = _make_settings(
base_url="https://gateway.ai.cloudflare.com/v1/a/b/compat",
token="cf-token-123",
)

client = build_gateway_client(settings)

assert client.api_key == "cf-token-123"
Loading
Loading