agharsallah commited on
Commit ·
eed2172
1
Parent(s): 7973a8e
feat: require live infrastructure for event store and inference, removing offline mode
Browse files- .env.example +13 -13
- config/models.yaml +7 -5
- docs/adr/0010-per-agent-model-routing.md +10 -3
- docs/adr/0014-postgres-event-store.md +13 -13
- pyproject.toml +14 -37
- src/core/ledger_factory.py +16 -14
- src/core/registry.py +17 -8
- src/models/router.py +14 -9
- tests/conftest.py +47 -0
- tests/test_live_wiring.py +38 -35
- tests/test_registry.py +12 -5
- tests/test_router.py +6 -6
- tests/test_sqlalchemy_ledger.py +6 -6
- uv.lock +11 -19
.env.example
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
# Copy to .env and fill in your values.
|
| 2 |
#
|
| 3 |
-
#
|
| 4 |
-
#
|
| 5 |
-
#
|
| 6 |
-
# backends (the Lab's §00 selector chooses per run): the small
|
| 7 |
-
# yourself from `modal/` (see modal/README.md), or Hugging Face's
|
| 8 |
-
# Inference Providers (just an HF_TOKEN). Either is small-model-only
|
| 9 |
-
# is no generic OpenAI/cloud path.
|
| 10 |
|
| 11 |
# ── Live models: the OpenAI-compatible small models served on Modal ────────────
|
| 12 |
# (ADR-0014 / 0015 / 0019). Setting MODAL_WORKSPACE activates the live path. Each
|
|
@@ -54,17 +54,17 @@ HF_INFERENCE_BASE_URL=
|
|
| 54 |
# MODEL_BALANCED=openai/google/gemma-4-12B
|
| 55 |
# MODEL_STRONG=openai/google/gemma-4-26B-A4B-it
|
| 56 |
|
| 57 |
-
# Durable event store backend (ADR-0014 Postgres)
|
| 58 |
-
# ledger is
|
| 59 |
-
#
|
| 60 |
# DATABASE_URL=postgresql+psycopg://USER:PASSWORD@HOST/DB?sslmode=require
|
| 61 |
-
# A local SQLite URL also works (e.g.
|
| 62 |
# DATABASE_URL=sqlite:///runs/events.db
|
| 63 |
DATABASE_URL=
|
| 64 |
|
| 65 |
# Semantic memory index (ADR-0018). Truthy activates a derived vector lens over
|
| 66 |
-
# the ledger. Embeddings run LOCALLY via sentence-transformers (no API key;
|
| 67 |
-
#
|
| 68 |
# Override the backend/embedder with a MEMORY_INDEX_CONFIG JSON blob (passed
|
| 69 |
# verbatim to mem0; see docs/architecture/memory-stack.md).
|
| 70 |
# MEMORY_INDEX=1
|
|
|
|
| 1 |
# Copy to .env and fill in your values.
|
| 2 |
#
|
| 3 |
+
# The app requires real infrastructure to run: a DATABASE_URL (durable event
|
| 4 |
+
# store) and a live inference backend. There is no offline/stub product mode —
|
| 5 |
+
# the deterministic stub now exists only as the test suite's mock data. Pick one
|
| 6 |
+
# of two inference backends (the Lab's §00 selector chooses per run): the small
|
| 7 |
+
# models you serve yourself from `modal/` (see modal/README.md), or Hugging Face's
|
| 8 |
+
# serverless Inference Providers (just an HF_TOKEN). Either is small-model-only
|
| 9 |
+
# (<=32B); there is no generic OpenAI/cloud path.
|
| 10 |
|
| 11 |
# ── Live models: the OpenAI-compatible small models served on Modal ────────────
|
| 12 |
# (ADR-0014 / 0015 / 0019). Setting MODAL_WORKSPACE activates the live path. Each
|
|
|
|
| 54 |
# MODEL_BALANCED=openai/google/gemma-4-12B
|
| 55 |
# MODEL_STRONG=openai/google/gemma-4-26B-A4B-it
|
| 56 |
|
| 57 |
+
# Durable event store backend (ADR-0014 Postgres) — REQUIRED. The append-only
|
| 58 |
+
# ledger is persisted through SQLAlchemy; the app refuses to run without a URL
|
| 59 |
+
# (there is no in-memory fallback). Neon (managed Postgres) form:
|
| 60 |
# DATABASE_URL=postgresql+psycopg://USER:PASSWORD@HOST/DB?sslmode=require
|
| 61 |
+
# A local SQLite URL also works (e.g. a single-box run without a server):
|
| 62 |
# DATABASE_URL=sqlite:///runs/events.db
|
| 63 |
DATABASE_URL=
|
| 64 |
|
| 65 |
# Semantic memory index (ADR-0018). Truthy activates a derived vector lens over
|
| 66 |
+
# the ledger. Embeddings run LOCALLY via sentence-transformers (no API key;
|
| 67 |
+
# cached after first use); sentence-transformers + mem0 ship as core deps.
|
| 68 |
# Override the backend/embedder with a MEMORY_INDEX_CONFIG JSON blob (passed
|
| 69 |
# verbatim to mem0; see docs/architecture/memory-stack.md).
|
| 70 |
# MEMORY_INDEX=1
|
config/models.yaml
CHANGED
|
@@ -1,17 +1,19 @@
|
|
| 1 |
# Logical model profiles → small models served on Modal.
|
| 2 |
#
|
| 3 |
# offline:
|
| 4 |
-
# null =
|
| 5 |
-
# MODAL_WORKSPACE
|
| 6 |
-
#
|
| 7 |
-
# false =
|
|
|
|
|
|
|
| 8 |
#
|
| 9 |
# Each profile binds to a model by its **catalogue key** (`endpoint:`) — the model
|
| 10 |
# slug in `modal/catalogue.py`, the single source of truth for what is deployed.
|
| 11 |
# The loader (`Registry.from_dir`) expands that key into the concrete binding:
|
| 12 |
# model = openai/<served_model_id>
|
| 13 |
# base_url = https://${MODAL_WORKSPACE}--<app>-<endpoint>.modal.run/v1
|
| 14 |
-
# (or $MODAL_LLM_BASE_URL if set;
|
| 15 |
# api_key = $MODAL_LLM_KEY (a self-served vLLM endpoint accepts any token)
|
| 16 |
#
|
| 17 |
# So adding/retuning a model is a one-line edit in `modal/catalogue.py`, and
|
|
|
|
| 1 |
# Logical model profiles → small models served on Modal.
|
| 2 |
#
|
| 3 |
# offline:
|
| 4 |
+
# null = live inference required (default). The build refuses to start if no
|
| 5 |
+
# backend is configured (MODAL_WORKSPACE / MODAL_LLM_BASE_URL / HF_TOKEN);
|
| 6 |
+
# there is no silent fallback to the stub.
|
| 7 |
+
# false = live inference (same as null, stated explicitly).
|
| 8 |
+
# true = deterministic stub for every profile — a test/dev seam (the test suite
|
| 9 |
+
# sets this), NOT a product mode.
|
| 10 |
#
|
| 11 |
# Each profile binds to a model by its **catalogue key** (`endpoint:`) — the model
|
| 12 |
# slug in `modal/catalogue.py`, the single source of truth for what is deployed.
|
| 13 |
# The loader (`Registry.from_dir`) expands that key into the concrete binding:
|
| 14 |
# model = openai/<served_model_id>
|
| 15 |
# base_url = https://${MODAL_WORKSPACE}--<app>-<endpoint>.modal.run/v1
|
| 16 |
+
# (or $MODAL_LLM_BASE_URL if set; a backend is required to run)
|
| 17 |
# api_key = $MODAL_LLM_KEY (a self-served vLLM endpoint accepts any token)
|
| 18 |
#
|
| 19 |
# So adding/retuning a model is a one-line edit in `modal/catalogue.py`, and
|
docs/adr/0010-per-agent-model-routing.md
CHANGED
|
@@ -24,8 +24,14 @@ inference there.
|
|
| 24 |
The router is config/env driven:
|
| 25 |
- `config/models.yaml` binds each profile to a concrete (small) model.
|
| 26 |
- `MODEL_TINY` / `MODEL_FAST` / `MODEL_BALANCED` / `MODEL_STRONG` override at runtime.
|
| 27 |
-
-
|
| 28 |
-
so
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
Providers expose `last_usage`; the conductor meters those tokens into the
|
| 31 |
Governor (see ADR-0013).
|
|
@@ -37,4 +43,5 @@ Governor (see ADR-0013).
|
|
| 37 |
- Swapping a tier to a different small model is a one-line config change; no agent
|
| 38 |
code names a model.
|
| 39 |
- The `tiny` profile (≤4B) gives a first-class Tiny Titan mode.
|
| 40 |
-
- The deterministic offline
|
|
|
|
|
|
| 24 |
The router is config/env driven:
|
| 25 |
- `config/models.yaml` binds each profile to a concrete (small) model.
|
| 26 |
- `MODEL_TINY` / `MODEL_FAST` / `MODEL_BALANCED` / `MODEL_STRONG` override at runtime.
|
| 27 |
+
- With `offline=True` it serves a `DeterministicTinyModel` for *every* profile,
|
| 28 |
+
so the test suite is reproducible with no inference.
|
| 29 |
+
|
| 30 |
+
> **Amended:** offline is no longer a *product* mode. The app requires live
|
| 31 |
+
> inference — `Registry.build_router()` raises when no backend is configured
|
| 32 |
+
> instead of serving the stub. The `offline=True` flag and `DeterministicTinyModel`
|
| 33 |
+
> are retained purely as the test/dev seam — the deterministic "mock data" the
|
| 34 |
+
> suite injects via `tests/conftest.py`.
|
| 35 |
|
| 36 |
Providers expose `last_usage`; the conductor meters those tokens into the
|
| 37 |
Governor (see ADR-0013).
|
|
|
|
| 43 |
- Swapping a tier to a different small model is a one-line config change; no agent
|
| 44 |
code names a model.
|
| 45 |
- The `tiny` profile (≤4B) gives a first-class Tiny Titan mode.
|
| 46 |
+
- The deterministic stub (the `offline=True` test seam) keeps the test suite green
|
| 47 |
+
and network-free without it being a runtime product mode.
|
docs/adr/0014-postgres-event-store.md
CHANGED
|
@@ -35,17 +35,16 @@ is a serial `offset` column (not `created_at`, which is subject to clock skew, n
|
|
| 35 |
exercised in CI against SQLite without a server, and the Neon path is
|
| 36 |
code-identical.
|
| 37 |
|
| 38 |
-
**
|
| 39 |
-
`make_ledger()`)
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
dependencies** in `pyproject.toml`
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
minimal/test contexts even though the driver is now always present.)
|
| 49 |
|
| 50 |
**SQLAlchemy-direct, not the `eventsourcing` library.** `eventsourcing` is built
|
| 51 |
around DDD aggregates: its `StoredEvent` is keyed on `originator_id` +
|
|
@@ -70,8 +69,9 @@ The two are complementary, not competing.
|
|
| 70 |
- A hosted deployment points `DATABASE_URL` at Neon
|
| 71 |
(`postgresql+psycopg://USER:PASSWORD@HOST/DB?sslmode=require`) and the durable
|
| 72 |
log lives in managed Postgres; everything else is unchanged.
|
| 73 |
-
-
|
| 74 |
-
|
|
|
|
| 75 |
- `snapshot_to` is backend-agnostic (it replays the log into a destination
|
| 76 |
ledger, default a SQLite file) since Postgres has no portable in-process backup
|
| 77 |
API like SQLite's `.backup()`; a Postgres run can be checkpointed to a portable
|
|
|
|
| 35 |
exercised in CI against SQLite without a server, and the Neon path is
|
| 36 |
code-identical.
|
| 37 |
|
| 38 |
+
**The durable store is required.** A small factory (`src/core/ledger_factory.py`,
|
| 39 |
+
`make_ledger()`) constructs the backend from `DATABASE_URL` (`SqlAlchemyLedger`).
|
| 40 |
+
There is **no in-memory fallback**: with no URL resolved, `make_ledger()` raises —
|
| 41 |
+
the app persists to a real event store and refuses to run without one (this is
|
| 42 |
+
part of dropping the offline product mode; see ADR-0010). The store deps
|
| 43 |
+
(`sqlalchemy>=2.0`, `psycopg[binary]>=3` — the Neon driver) are therefore **core
|
| 44 |
+
dependencies** in `pyproject.toml`, not an optional extra. SQLAlchemy is still
|
| 45 |
+
imported lazily inside the backend (so `src.core.*` stays importable in minimal
|
| 46 |
+
contexts), but it always ships. Tests pass an explicit ephemeral `sqlite://` URL
|
| 47 |
+
as the mock store — a real `SqlAlchemyLedger` with no server.
|
|
|
|
| 48 |
|
| 49 |
**SQLAlchemy-direct, not the `eventsourcing` library.** `eventsourcing` is built
|
| 50 |
around DDD aggregates: its `StoredEvent` is keyed on `originator_id` +
|
|
|
|
| 69 |
- A hosted deployment points `DATABASE_URL` at Neon
|
| 70 |
(`postgresql+psycopg://USER:PASSWORD@HOST/DB?sslmode=require`) and the durable
|
| 71 |
log lives in managed Postgres; everything else is unchanged.
|
| 72 |
+
- A `DATABASE_URL` is required to run; `make_ledger()` raises without one. Tests
|
| 73 |
+
pass an ephemeral `sqlite://` URL (a real `SqlAlchemyLedger`, no server) as the
|
| 74 |
+
mock store, so the suite stays green with no database server and no network.
|
| 75 |
- `snapshot_to` is backend-agnostic (it replays the log into a destination
|
| 76 |
ledger, default a SQLite file) since Postgres has no portable in-process backup
|
| 77 |
API like SQLite's `.backup()`; a Postgres run can be checkpointed to a portable
|
pyproject.toml
CHANGED
|
@@ -14,10 +14,22 @@ dependencies = [
|
|
| 14 |
"opentelemetry-api>=1.20.0",
|
| 15 |
"opentelemetry-sdk>=1.20.0",
|
| 16 |
"opentelemetry-semantic-conventions>=0.41b0",
|
| 17 |
-
#
|
| 18 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
"sqlalchemy>=2.0",
|
| 20 |
"psycopg[binary]>=3",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
]
|
| 22 |
|
| 23 |
[project.optional-dependencies]
|
|
@@ -25,41 +37,6 @@ dev = [
|
|
| 25 |
"pytest>=8.2.0",
|
| 26 |
"ruff>=0.5.0",
|
| 27 |
]
|
| 28 |
-
# Model gateway (ADR-0015). Optional: routes live profiles through LiteLLM to the
|
| 29 |
-
# OpenAI-compatible models served on Modal, with real per-call cost metering. The
|
| 30 |
-
# system runs fully offline on the deterministic stub without this installed;
|
| 31 |
-
# litellm is imported lazily so importing src.models.* never requires it.
|
| 32 |
-
litellm = [
|
| 33 |
-
"litellm>=1.40",
|
| 34 |
-
]
|
| 35 |
-
# Validated structured output (ADR-0016). Optional: wraps the LiteLLM gateway
|
| 36 |
-
# with Instructor so the live path returns schema-valid, kind-constrained event
|
| 37 |
-
# payloads (retried on validation failure) instead of parsing prose. The system
|
| 38 |
-
# runs fully offline on the deterministic stub + tolerant parser without this;
|
| 39 |
-
# instructor is imported lazily so importing src.* never requires it.
|
| 40 |
-
instructor = [
|
| 41 |
-
"instructor>=1.5",
|
| 42 |
-
]
|
| 43 |
-
# MCP tool transport (ADR-0017). Optional: exposes the built-in tools over a
|
| 44 |
-
# stdio Model Context Protocol server and resolves granted tools over it when the
|
| 45 |
-
# MCP_SERVERS / MCP_ORACLE gate is set. The capability check still runs first, so
|
| 46 |
-
# MCP is transport, not the security boundary. The system runs fully offline on
|
| 47 |
-
# the in-process registry without this; mcp is imported lazily so importing src.*
|
| 48 |
-
# and app never requires it.
|
| 49 |
-
mcp = [
|
| 50 |
-
"mcp>=1.0",
|
| 51 |
-
]
|
| 52 |
-
# Semantic memory index (ADR-0018). Optional: a derived, rebuildable vector lens
|
| 53 |
-
# over the event ledger that upgrades the salience relevance term from keyword
|
| 54 |
-
# overlap to semantic search when the MEMORY_INDEX gate is set. The ledger stays
|
| 55 |
-
# the single source of truth; the index is repopulated from it. The system runs
|
| 56 |
-
# fully offline on the keyword path without this; mem0 is imported lazily so
|
| 57 |
-
# importing src.* and app never requires it. Embeddings run LOCALLY via
|
| 58 |
-
# sentence-transformers (no API key) — keeping the active index off the grid too.
|
| 59 |
-
memory = [
|
| 60 |
-
"mem0ai>=0.1",
|
| 61 |
-
"sentence-transformers>=3.0",
|
| 62 |
-
]
|
| 63 |
|
| 64 |
[tool.ruff]
|
| 65 |
line-length = 120
|
|
|
|
| 14 |
"opentelemetry-api>=1.20.0",
|
| 15 |
"opentelemetry-sdk>=1.20.0",
|
| 16 |
"opentelemetry-semantic-conventions>=0.41b0",
|
| 17 |
+
# The app requires real infrastructure — a DB and live inference — and refuses to
|
| 18 |
+
# run without it; there is no offline/stub product mode. Everything the live path
|
| 19 |
+
# touches is therefore a core dependency rather than an opt-in extra:
|
| 20 |
+
# - sqlalchemy + psycopg : durable event store backend (ADR-0014); psycopg is the Neon driver.
|
| 21 |
+
# - litellm : model gateway to the OpenAI-compatible endpoints (ADR-0015).
|
| 22 |
+
# - instructor : validated, schema-constrained structured output (ADR-0016).
|
| 23 |
+
# - mcp : Model Context Protocol tool transport (ADR-0017).
|
| 24 |
+
# - mem0ai + sentence-transformers : semantic memory index over the ledger (ADR-0018).
|
| 25 |
+
# These are still imported lazily at their call sites, but they always ship.
|
| 26 |
"sqlalchemy>=2.0",
|
| 27 |
"psycopg[binary]>=3",
|
| 28 |
+
"litellm>=1.40",
|
| 29 |
+
"instructor>=1.5",
|
| 30 |
+
"mcp>=1.0",
|
| 31 |
+
"mem0ai>=0.1",
|
| 32 |
+
"sentence-transformers>=3.0",
|
| 33 |
]
|
| 34 |
|
| 35 |
[project.optional-dependencies]
|
|
|
|
| 37 |
"pytest>=8.2.0",
|
| 38 |
"ruff>=0.5.0",
|
| 39 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
[tool.ruff]
|
| 42 |
line-length = 120
|
src/core/ledger_factory.py
CHANGED
|
@@ -1,15 +1,13 @@
|
|
| 1 |
-
"""Backend selection for the event ledger —
|
| 2 |
|
| 3 |
One decision lives here: which ``Ledger`` backend to construct. The append-only
|
| 4 |
ledger is the single source of truth (ADR-0014); this only chooses *where* it is
|
| 5 |
durably stored.
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
With no ``DATABASE_URL`` the system never imports SQLAlchemy or a database driver,
|
| 12 |
-
so the offline path stays import-clean and fully testable without a server.
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
|
@@ -29,8 +27,8 @@ def _normalize_db_url(url: str) -> str:
|
|
| 29 |
"""Steer a bare Postgres URL to the installed psycopg3 driver.
|
| 30 |
|
| 31 |
Neon (and most providers) hand out ``postgresql://`` / ``postgres://``, which
|
| 32 |
-
SQLAlchemy maps to psycopg2 — but this project ships psycopg3
|
| 33 |
-
|
| 34 |
Rewrite the bare scheme to ``postgresql+psycopg://``; URLs that already name a
|
| 35 |
driver (``postgresql+...``) or use another backend (sqlite, …) pass through.
|
| 36 |
"""
|
|
@@ -41,15 +39,19 @@ def _normalize_db_url(url: str) -> str:
|
|
| 41 |
|
| 42 |
|
| 43 |
def make_ledger(url: str | None = None) -> Ledger:
|
| 44 |
-
"""Construct the
|
| 45 |
|
| 46 |
-
*url* overrides ``DATABASE_URL`` (useful for tests/scripts
|
| 47 |
-
|
| 48 |
-
|
| 49 |
"""
|
| 50 |
resolved = url or database_url()
|
| 51 |
if not resolved:
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
from src.core.sqlalchemy_ledger import SqlAlchemyLedger
|
| 54 |
|
| 55 |
return SqlAlchemyLedger(_normalize_db_url(resolved))
|
|
|
|
| 1 |
+
"""Backend selection for the event ledger — the durable store is required.
|
| 2 |
|
| 3 |
One decision lives here: which ``Ledger`` backend to construct. The append-only
|
| 4 |
ledger is the single source of truth (ADR-0014); this only chooses *where* it is
|
| 5 |
durably stored.
|
| 6 |
|
| 7 |
+
A ``DATABASE_URL`` (Postgres/Neon, or any SQLAlchemy URL — e.g. ``sqlite://`` for
|
| 8 |
+
an in-memory store in tests) is **required**: the app persists to a real event
|
| 9 |
+
store and refuses to run without one. Construction raises when no URL is
|
| 10 |
+
resolved rather than silently degrading to an ephemeral in-memory ledger.
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
|
|
| 27 |
"""Steer a bare Postgres URL to the installed psycopg3 driver.
|
| 28 |
|
| 29 |
Neon (and most providers) hand out ``postgresql://`` / ``postgres://``, which
|
| 30 |
+
SQLAlchemy maps to psycopg2 — but this project ships psycopg3, so a
|
| 31 |
+
copy-pasted Neon URL would fail with a missing-driver error.
|
| 32 |
Rewrite the bare scheme to ``postgresql+psycopg://``; URLs that already name a
|
| 33 |
driver (``postgresql+...``) or use another backend (sqlite, …) pass through.
|
| 34 |
"""
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
def make_ledger(url: str | None = None) -> Ledger:
|
| 42 |
+
"""Construct the durable ledger backend (required — never an in-memory fallback).
|
| 43 |
|
| 44 |
+
*url* overrides ``DATABASE_URL`` (useful for tests/scripts — pass ``"sqlite://"``
|
| 45 |
+
for an ephemeral in-memory store). Raises :class:`RuntimeError` when neither is
|
| 46 |
+
set: the app requires a real event store and must not silently run without one.
|
| 47 |
"""
|
| 48 |
resolved = url or database_url()
|
| 49 |
if not resolved:
|
| 50 |
+
raise RuntimeError(
|
| 51 |
+
"DATABASE_URL is required — the event store is not optional. "
|
| 52 |
+
"Set DATABASE_URL (e.g. a Neon postgresql:// URL, or sqlite:///runs/events.db for "
|
| 53 |
+
"a local file), or pass an explicit url to make_ledger()."
|
| 54 |
+
)
|
| 55 |
from src.core.sqlalchemy_ledger import SqlAlchemyLedger
|
| 56 |
|
| 57 |
return SqlAlchemyLedger(_normalize_db_url(resolved))
|
src/core/registry.py
CHANGED
|
@@ -197,17 +197,26 @@ class Registry:
|
|
| 197 |
# ── building ───────────────────────────────────────────────────────────────
|
| 198 |
|
| 199 |
def build_router(self) -> ModelRouter:
|
| 200 |
-
"""Construct a ModelRouter honouring the models config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
specs = {profile: ProfileSpec(**cfg.model_dump()) for profile, cfg in self.models.profiles.items()}
|
| 202 |
if self.models.offline is True:
|
| 203 |
return ModelRouter(offline=True, specs=specs)
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
| 211 |
|
| 212 |
def build_agent(self, name: str, router: ModelRouter, tools=None, memory_index=None) -> Agent:
|
| 213 |
if name not in self.agents:
|
|
|
|
| 197 |
# ── building ───────────────────────────────────────────────────────────────
|
| 198 |
|
| 199 |
def build_router(self) -> ModelRouter:
|
| 200 |
+
"""Construct a ModelRouter honouring the models config.
|
| 201 |
+
|
| 202 |
+
``models.offline: true`` forces the deterministic stub (the test/dev seam);
|
| 203 |
+
``false`` and the default (``null``) both build the live path. On the live
|
| 204 |
+
path live inference is **required**: if no backend is configured the build
|
| 205 |
+
raises rather than silently degrading to the stub — the app has no offline
|
| 206 |
+
product mode.
|
| 207 |
+
"""
|
| 208 |
specs = {profile: ProfileSpec(**cfg.model_dump()) for profile, cfg in self.models.profiles.items()}
|
| 209 |
if self.models.offline is True:
|
| 210 |
return ModelRouter(offline=True, specs=specs)
|
| 211 |
+
from src.models.openai_compat import has_live_credentials
|
| 212 |
+
|
| 213 |
+
if not has_live_credentials():
|
| 214 |
+
raise RuntimeError(
|
| 215 |
+
"No inference backend configured. Set MODAL_WORKSPACE / MODAL_LLM_BASE_URL "
|
| 216 |
+
"or HF_TOKEN / HF_INFERENCE_BASE_URL to run live, or set models.offline: true "
|
| 217 |
+
"for the deterministic stub (tests/dev only)."
|
| 218 |
+
)
|
| 219 |
+
return ModelRouter(offline=False, specs=specs)
|
| 220 |
|
| 221 |
def build_agent(self, name: str, router: ModelRouter, tools=None, memory_index=None) -> Agent:
|
| 222 |
if name not in self.agents:
|
src/models/router.py
CHANGED
|
@@ -9,9 +9,12 @@ This is the single place per-agent model selection happens, so:
|
|
| 9 |
- a scenario can mix a ``tiny`` worker with a ``strong`` judge for free;
|
| 10 |
- the rest of the engine never names a model.
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
On the live path the concrete transport is the :class:`LiteLLMProvider` gateway
|
| 17 |
(ADR-0015): profiles point at the OpenAI-compatible Modal/vLLM endpoints in
|
|
@@ -25,7 +28,6 @@ from dataclasses import dataclass, field
|
|
| 25 |
|
| 26 |
from src import observability as obs
|
| 27 |
from src.core.manifest import ModelProfile, resolve_model
|
| 28 |
-
from src.models.openai_compat import has_live_credentials
|
| 29 |
from src.models.provider import DeterministicTinyModel, ModelProvider
|
| 30 |
|
| 31 |
# Decoding defaults per profile. Smaller models stay cooler and shorter; the
|
|
@@ -62,6 +64,8 @@ class ModelRouter:
|
|
| 62 |
"""
|
| 63 |
|
| 64 |
specs: dict[str, ProfileSpec] = field(default_factory=dict)
|
|
|
|
|
|
|
| 65 |
offline: bool = False
|
| 66 |
_cache: dict[str, ModelProvider] = field(default_factory=dict, init=False, repr=False)
|
| 67 |
|
|
@@ -156,10 +160,11 @@ class ModelRouter:
|
|
| 156 |
|
| 157 |
@classmethod
|
| 158 |
def from_env(cls) -> "ModelRouter":
|
| 159 |
-
"""Build a router from environment configuration.
|
| 160 |
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
| 164 |
"""
|
| 165 |
-
return cls(offline=
|
|
|
|
| 9 |
- a scenario can mix a ``tiny`` worker with a ``strong`` judge for free;
|
| 10 |
- the rest of the engine never names a model.
|
| 11 |
|
| 12 |
+
The live path is the only product path: profiles resolve to concrete models and
|
| 13 |
+
the router calls them over the gateway. The ``offline`` flag is a *test seam* —
|
| 14 |
+
when set it serves a :class:`DeterministicTinyModel` for every profile so the
|
| 15 |
+
suite runs with zero inference and full reproducibility (the deterministic
|
| 16 |
+
"mock data"). Production never sets it; ``Registry.build_router`` requires live
|
| 17 |
+
credentials and refuses to construct a stub router. See ADR-0010.
|
| 18 |
|
| 19 |
On the live path the concrete transport is the :class:`LiteLLMProvider` gateway
|
| 20 |
(ADR-0015): profiles point at the OpenAI-compatible Modal/vLLM endpoints in
|
|
|
|
| 28 |
|
| 29 |
from src import observability as obs
|
| 30 |
from src.core.manifest import ModelProfile, resolve_model
|
|
|
|
| 31 |
from src.models.provider import DeterministicTinyModel, ModelProvider
|
| 32 |
|
| 33 |
# Decoding defaults per profile. Smaller models stay cooler and shorter; the
|
|
|
|
| 64 |
"""
|
| 65 |
|
| 66 |
specs: dict[str, ProfileSpec] = field(default_factory=dict)
|
| 67 |
+
# Test seam only: serve the deterministic stub for every profile. Production
|
| 68 |
+
# never sets this (Registry.build_router requires live credentials).
|
| 69 |
offline: bool = False
|
| 70 |
_cache: dict[str, ModelProvider] = field(default_factory=dict, init=False, repr=False)
|
| 71 |
|
|
|
|
| 160 |
|
| 161 |
@classmethod
|
| 162 |
def from_env(cls) -> "ModelRouter":
|
| 163 |
+
"""Build a live router from environment configuration.
|
| 164 |
|
| 165 |
+
Each profile resolves to its concrete model via ``resolve_model`` plus the
|
| 166 |
+
per-profile decoding defaults (``MODEL_TINY`` / … overrides honoured). This
|
| 167 |
+
always builds the live path; the deterministic stub is reached only by
|
| 168 |
+
constructing ``ModelRouter(offline=True)`` explicitly (the test seam).
|
| 169 |
"""
|
| 170 |
+
return cls(offline=False)
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Suite-wide test fixtures: the deterministic "mock data" the app no longer ships.
|
| 2 |
+
|
| 3 |
+
The app has no offline product mode — it requires a real event store
|
| 4 |
+
(``DATABASE_URL``) and live inference, and refuses to run without them. The test
|
| 5 |
+
suite must still run with zero network, no credentials, and full reproducibility,
|
| 6 |
+
so it supplies that infrastructure as *mock data* instead:
|
| 7 |
+
|
| 8 |
+
* an ephemeral in-memory SQLite event store (a real ``SqlAlchemyLedger``, no server);
|
| 9 |
+
* the :class:`~src.models.provider.DeterministicTinyModel` stub for every profile,
|
| 10 |
+
via ``ModelRouter(offline=True)`` — wired in by patching ``Registry.build_router``.
|
| 11 |
+
|
| 12 |
+
A test that needs the *real* production wiring (e.g. to assert that a missing DB
|
| 13 |
+
URL or missing credentials raises) opts out with ``@pytest.mark.real_infra`` and
|
| 14 |
+
sets up its own environment.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import pytest
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def pytest_configure(config):
|
| 23 |
+
config.addinivalue_line(
|
| 24 |
+
"markers",
|
| 25 |
+
"real_infra: use the real make_ledger/build_router wiring instead of the mock fixtures",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@pytest.fixture(autouse=True)
|
| 30 |
+
def _mock_infra(request, monkeypatch):
|
| 31 |
+
# An ephemeral SQLite store stands in for the required durable event store.
|
| 32 |
+
monkeypatch.setenv("DATABASE_URL", "sqlite://")
|
| 33 |
+
|
| 34 |
+
if request.node.get_closest_marker("real_infra"):
|
| 35 |
+
return
|
| 36 |
+
|
| 37 |
+
# Route every profile to the deterministic stub so tests never reach live
|
| 38 |
+
# inference. Patching build_router (rather than env) keeps production code's
|
| 39 |
+
# "live credentials required" contract intact while tests get reproducible data.
|
| 40 |
+
from src.core.registry import Registry
|
| 41 |
+
from src.models.router import ModelRouter, ProfileSpec
|
| 42 |
+
|
| 43 |
+
def _stub_build_router(self) -> ModelRouter:
|
| 44 |
+
specs = {profile: ProfileSpec(**cfg.model_dump()) for profile, cfg in self.models.profiles.items()}
|
| 45 |
+
return ModelRouter(offline=True, specs=specs)
|
| 46 |
+
|
| 47 |
+
monkeypatch.setattr(Registry, "build_router", _stub_build_router)
|
tests/test_live_wiring.py
CHANGED
|
@@ -1,31 +1,34 @@
|
|
| 1 |
-
"""Guard the env-gating contract for the live
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
| 5 |
|
| 6 |
-
* Models — :
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
* Ledger — :func:`~src.core.ledger_factory.make_ledger`
|
| 10 |
-
|
| 11 |
* Memory — :func:`~src.core.memory_index.memory_index_from_env` is ``None`` when
|
| 12 |
the gate is unset and a cloud index when ``MEMORY_INDEX=cloud``.
|
| 13 |
|
| 14 |
-
Everything runs on ``monkeypatch`` env edits (auto-reverted)
|
| 15 |
-
URL
|
| 16 |
-
real
|
| 17 |
"""
|
| 18 |
|
| 19 |
from __future__ import annotations
|
| 20 |
|
| 21 |
import pytest
|
| 22 |
|
| 23 |
-
from src.core.ledger import Ledger
|
| 24 |
from src.core.ledger_factory import make_ledger
|
| 25 |
from src.core.memory_index import memory_index_from_env
|
| 26 |
from src.models.openai_compat import has_live_credentials
|
|
|
|
| 27 |
from src.models.router import ModelRouter
|
| 28 |
|
|
|
|
|
|
|
| 29 |
# Env vars these tests touch; cleared before each case so the host environment and
|
| 30 |
# sibling tests never leak into (or out of) a case.
|
| 31 |
_LIVE_ENV = (
|
|
@@ -45,58 +48,58 @@ def _clean_live_env(monkeypatch):
|
|
| 45 |
monkeypatch.delenv(name, raising=False)
|
| 46 |
|
| 47 |
|
| 48 |
-
# ── models:
|
| 49 |
|
| 50 |
|
| 51 |
-
class
|
| 52 |
def test_has_live_credentials_false_when_unset(self):
|
| 53 |
assert has_live_credentials() is False
|
| 54 |
|
| 55 |
-
def test_router_from_env_is_offline_when_unset(self):
|
| 56 |
-
assert ModelRouter.from_env().offline is True
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
class TestModelsLiveWhenBound:
|
| 60 |
def test_workspace_marks_live_credentials(self, monkeypatch):
|
| 61 |
monkeypatch.setenv("MODAL_WORKSPACE", "demo-workspace")
|
| 62 |
assert has_live_credentials() is True
|
| 63 |
|
| 64 |
-
def test_workspace_makes_router_live(self, monkeypatch):
|
| 65 |
-
monkeypatch.setenv("MODAL_WORKSPACE", "demo-workspace")
|
| 66 |
-
assert ModelRouter.from_env().offline is False
|
| 67 |
-
|
| 68 |
def test_base_url_marks_live_credentials(self, monkeypatch):
|
| 69 |
monkeypatch.setenv("MODAL_LLM_BASE_URL", "https://demo.modal.run/v1")
|
| 70 |
assert has_live_credentials() is True
|
| 71 |
|
| 72 |
-
def
|
| 73 |
-
monkeypatch.setenv("MODAL_LLM_BASE_URL", "https://demo.modal.run/v1")
|
| 74 |
-
assert ModelRouter.from_env().offline is False
|
| 75 |
-
|
| 76 |
-
def test_blank_workspace_stays_offline(self, monkeypatch):
|
| 77 |
# An empty/whitespace value must NOT count as a live binding.
|
| 78 |
monkeypatch.setenv("MODAL_WORKSPACE", " ")
|
| 79 |
assert has_live_credentials() is False
|
| 80 |
-
assert ModelRouter.from_env().offline is True
|
| 81 |
|
| 82 |
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
|
| 86 |
class TestLedgerSelection:
|
| 87 |
-
def
|
| 88 |
-
|
| 89 |
-
|
| 90 |
|
| 91 |
def test_sqlite_url_returns_sqlalchemy_ledger(self):
|
| 92 |
-
pytest.importorskip("sqlalchemy")
|
| 93 |
from src.core.sqlalchemy_ledger import SqlAlchemyLedger
|
| 94 |
|
| 95 |
ledger = make_ledger("sqlite:///:memory:")
|
| 96 |
assert isinstance(ledger, SqlAlchemyLedger)
|
| 97 |
|
| 98 |
def test_sqlite_ledger_append_read_roundtrip(self):
|
| 99 |
-
pytest.importorskip("sqlalchemy")
|
| 100 |
from src.core.events import Event
|
| 101 |
|
| 102 |
ledger = make_ledger("sqlite:///:memory:")
|
|
|
|
| 1 |
+
"""Guard the env-gating contract for the live wiring (no network, no creds).
|
| 2 |
|
| 3 |
+
The app has no offline product mode: it requires live inference and a real event
|
| 4 |
+
store and refuses to run without them. These tests pin that *selection* behaviour
|
| 5 |
+
so it cannot silently regress:
|
| 6 |
|
| 7 |
+
* Models — :func:`~src.models.openai_compat.has_live_credentials` is False unless a
|
| 8 |
+
Modal/HF binding is set, and :meth:`ModelRouter.from_env` always builds the live
|
| 9 |
+
path (the deterministic stub is reachable only via ``ModelRouter(offline=True)``).
|
| 10 |
+
* Ledger — :func:`~src.core.ledger_factory.make_ledger` raises with no ``DATABASE_URL``
|
| 11 |
+
and returns the ``SqlAlchemyLedger`` for a URL.
|
| 12 |
* Memory — :func:`~src.core.memory_index.memory_index_from_env` is ``None`` when
|
| 13 |
the gate is unset and a cloud index when ``MEMORY_INDEX=cloud``.
|
| 14 |
|
| 15 |
+
Everything runs on ``monkeypatch`` env edits (auto-reverted) and an in-memory SQLite
|
| 16 |
+
URL so the suite never touches a real network, a database server, or live credentials.
|
| 17 |
+
These cases assert the real wiring, so they opt out of the mock-infra fixture.
|
| 18 |
"""
|
| 19 |
|
| 20 |
from __future__ import annotations
|
| 21 |
|
| 22 |
import pytest
|
| 23 |
|
|
|
|
| 24 |
from src.core.ledger_factory import make_ledger
|
| 25 |
from src.core.memory_index import memory_index_from_env
|
| 26 |
from src.models.openai_compat import has_live_credentials
|
| 27 |
+
from src.models.provider import DeterministicTinyModel
|
| 28 |
from src.models.router import ModelRouter
|
| 29 |
|
| 30 |
+
pytestmark = pytest.mark.real_infra
|
| 31 |
+
|
| 32 |
# Env vars these tests touch; cleared before each case so the host environment and
|
| 33 |
# sibling tests never leak into (or out of) a case.
|
| 34 |
_LIVE_ENV = (
|
|
|
|
| 48 |
monkeypatch.delenv(name, raising=False)
|
| 49 |
|
| 50 |
|
| 51 |
+
# ── models: always live; the stub is reachable only by explicit opt-in ─────────
|
| 52 |
|
| 53 |
|
| 54 |
+
class TestCredentialDetection:
|
| 55 |
def test_has_live_credentials_false_when_unset(self):
|
| 56 |
assert has_live_credentials() is False
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
def test_workspace_marks_live_credentials(self, monkeypatch):
|
| 59 |
monkeypatch.setenv("MODAL_WORKSPACE", "demo-workspace")
|
| 60 |
assert has_live_credentials() is True
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
def test_base_url_marks_live_credentials(self, monkeypatch):
|
| 63 |
monkeypatch.setenv("MODAL_LLM_BASE_URL", "https://demo.modal.run/v1")
|
| 64 |
assert has_live_credentials() is True
|
| 65 |
|
| 66 |
+
def test_blank_workspace_is_not_a_binding(self, monkeypatch):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
# An empty/whitespace value must NOT count as a live binding.
|
| 68 |
monkeypatch.setenv("MODAL_WORKSPACE", " ")
|
| 69 |
assert has_live_credentials() is False
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
+
class TestRouterAlwaysLive:
|
| 73 |
+
def test_from_env_is_live_when_unset(self):
|
| 74 |
+
# No offline auto-detection: from_env always builds the live path.
|
| 75 |
+
router = ModelRouter.from_env()
|
| 76 |
+
assert router.offline is False
|
| 77 |
+
assert not isinstance(router.for_profile("fast"), DeterministicTinyModel)
|
| 78 |
+
|
| 79 |
+
def test_from_env_is_live_when_bound(self, monkeypatch):
|
| 80 |
+
monkeypatch.setenv("MODAL_WORKSPACE", "demo-workspace")
|
| 81 |
+
assert ModelRouter.from_env().offline is False
|
| 82 |
+
|
| 83 |
+
def test_stub_only_via_explicit_offline(self):
|
| 84 |
+
# The deterministic stub is the test seam, never selected automatically.
|
| 85 |
+
assert isinstance(ModelRouter(offline=True).for_profile("fast"), DeterministicTinyModel)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ─�� ledger: required — raises with no URL, SqlAlchemy-backed for a URL ──────────
|
| 89 |
|
| 90 |
|
| 91 |
class TestLedgerSelection:
|
| 92 |
+
def test_no_url_raises(self):
|
| 93 |
+
with pytest.raises(RuntimeError, match="DATABASE_URL is required"):
|
| 94 |
+
make_ledger()
|
| 95 |
|
| 96 |
def test_sqlite_url_returns_sqlalchemy_ledger(self):
|
|
|
|
| 97 |
from src.core.sqlalchemy_ledger import SqlAlchemyLedger
|
| 98 |
|
| 99 |
ledger = make_ledger("sqlite:///:memory:")
|
| 100 |
assert isinstance(ledger, SqlAlchemyLedger)
|
| 101 |
|
| 102 |
def test_sqlite_ledger_append_read_roundtrip(self):
|
|
|
|
| 103 |
from src.core.events import Event
|
| 104 |
|
| 105 |
ledger = make_ledger("sqlite:///:memory:")
|
tests/test_registry.py
CHANGED
|
@@ -30,14 +30,21 @@ class TestDefaultRegistry:
|
|
| 30 |
assert profiles["pocket-actor"] == "tiny"
|
| 31 |
assert profiles["mischief-critic"] == "balanced"
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
| 36 |
monkeypatch.delenv(var, raising=False)
|
| 37 |
reg = default_registry()
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
assert isinstance(router, ModelRouter)
|
| 40 |
-
assert router.offline is
|
| 41 |
|
| 42 |
def test_governor_for_uses_config_budget(self):
|
| 43 |
reg = default_registry()
|
|
|
|
| 30 |
assert profiles["pocket-actor"] == "tiny"
|
| 31 |
assert profiles["mischief-critic"] == "balanced"
|
| 32 |
|
| 33 |
+
@pytest.mark.real_infra
|
| 34 |
+
def test_build_router_requires_live_credentials(self, monkeypatch):
|
| 35 |
+
# No backend configured → the app refuses to build a router (no offline mode).
|
| 36 |
+
for var in ("MODAL_WORKSPACE", "MODAL_LLM_BASE_URL", "OPENAI_API_KEY", "HF_TOKEN", "HF_INFERENCE_BASE_URL"):
|
| 37 |
monkeypatch.delenv(var, raising=False)
|
| 38 |
reg = default_registry()
|
| 39 |
+
with pytest.raises(RuntimeError, match="No inference backend configured"):
|
| 40 |
+
reg.build_router()
|
| 41 |
+
|
| 42 |
+
@pytest.mark.real_infra
|
| 43 |
+
def test_build_router_is_live_with_credentials(self, monkeypatch):
|
| 44 |
+
monkeypatch.setenv("MODAL_WORKSPACE", "demo-workspace")
|
| 45 |
+
router = default_registry().build_router()
|
| 46 |
assert isinstance(router, ModelRouter)
|
| 47 |
+
assert router.offline is False
|
| 48 |
|
| 49 |
def test_governor_for_uses_config_budget(self):
|
| 50 |
reg = default_registry()
|
tests/test_router.py
CHANGED
|
@@ -121,7 +121,7 @@ class TestModelRouterCatalogueEndpoint:
|
|
| 121 |
assert provider.model == "openai/Qwen/Qwen2.5-7B-Instruct"
|
| 122 |
assert provider.api_base == "https://router.huggingface.co/v1"
|
| 123 |
assert provider.api_key == "hf_secret"
|
| 124 |
-
assert provider.max_tokens ==
|
| 125 |
|
| 126 |
def test_offline_hf_endpoint_key_serves_distinct_stub(self):
|
| 127 |
# Offline, an HF key routes like any profile: the deterministic stub with the
|
|
@@ -133,9 +133,9 @@ class TestModelRouterCatalogueEndpoint:
|
|
| 133 |
|
| 134 |
|
| 135 |
class TestFromEnv:
|
| 136 |
-
def
|
| 137 |
-
# No
|
| 138 |
-
#
|
| 139 |
for var in (
|
| 140 |
"MODAL_WORKSPACE",
|
| 141 |
"MODAL_LLM_BASE_URL",
|
|
@@ -146,8 +146,8 @@ class TestFromEnv:
|
|
| 146 |
):
|
| 147 |
monkeypatch.delenv(var, raising=False)
|
| 148 |
router = ModelRouter.from_env()
|
| 149 |
-
assert router.offline is
|
| 150 |
-
assert isinstance(router.for_profile("fast"), DeterministicTinyModel)
|
| 151 |
|
| 152 |
def test_online_with_modal_workspace(self, monkeypatch):
|
| 153 |
# A Modal workspace is the activating signal for the live path.
|
|
|
|
| 121 |
assert provider.model == "openai/Qwen/Qwen2.5-7B-Instruct"
|
| 122 |
assert provider.api_base == "https://router.huggingface.co/v1"
|
| 123 |
assert provider.api_key == "hf_secret"
|
| 124 |
+
assert provider.max_tokens == 320 # fast tier decoding (the model's tier)
|
| 125 |
|
| 126 |
def test_offline_hf_endpoint_key_serves_distinct_stub(self):
|
| 127 |
# Offline, an HF key routes like any profile: the deterministic stub with the
|
|
|
|
| 133 |
|
| 134 |
|
| 135 |
class TestFromEnv:
|
| 136 |
+
def test_live_without_binding(self, monkeypatch):
|
| 137 |
+
# No offline auto-detection: from_env always builds the live path, even with
|
| 138 |
+
# no backend configured (the stub is reachable only via offline=True).
|
| 139 |
for var in (
|
| 140 |
"MODAL_WORKSPACE",
|
| 141 |
"MODAL_LLM_BASE_URL",
|
|
|
|
| 146 |
):
|
| 147 |
monkeypatch.delenv(var, raising=False)
|
| 148 |
router = ModelRouter.from_env()
|
| 149 |
+
assert router.offline is False
|
| 150 |
+
assert not isinstance(router.for_profile("fast"), DeterministicTinyModel)
|
| 151 |
|
| 152 |
def test_online_with_modal_workspace(self, monkeypatch):
|
| 153 |
# A Modal workspace is the activating signal for the live path.
|
tests/test_sqlalchemy_ledger.py
CHANGED
|
@@ -7,7 +7,6 @@ from pathlib import Path
|
|
| 7 |
import pytest
|
| 8 |
|
| 9 |
from src.core.events import Event
|
| 10 |
-
from src.core.ledger import Ledger
|
| 11 |
from src.core.ledger_factory import make_ledger
|
| 12 |
from src.core.sqlalchemy_ledger import SqlAlchemyLedger
|
| 13 |
|
|
@@ -110,14 +109,15 @@ class TestSqlAlchemyLedgerPersistence:
|
|
| 110 |
|
| 111 |
|
| 112 |
class TestLedgerFactory:
|
| 113 |
-
def
|
| 114 |
monkeypatch.delenv("DATABASE_URL", raising=False)
|
| 115 |
-
|
| 116 |
-
|
| 117 |
|
| 118 |
-
def
|
| 119 |
monkeypatch.setenv("DATABASE_URL", "")
|
| 120 |
-
|
|
|
|
| 121 |
|
| 122 |
def test_explicit_url_builds_sqlalchemy_backend(self):
|
| 123 |
with tempfile.TemporaryDirectory() as tmpdir:
|
|
|
|
| 7 |
import pytest
|
| 8 |
|
| 9 |
from src.core.events import Event
|
|
|
|
| 10 |
from src.core.ledger_factory import make_ledger
|
| 11 |
from src.core.sqlalchemy_ledger import SqlAlchemyLedger
|
| 12 |
|
|
|
|
| 109 |
|
| 110 |
|
| 111 |
class TestLedgerFactory:
|
| 112 |
+
def test_missing_database_url_raises(self, monkeypatch):
|
| 113 |
monkeypatch.delenv("DATABASE_URL", raising=False)
|
| 114 |
+
with pytest.raises(RuntimeError, match="DATABASE_URL is required"):
|
| 115 |
+
make_ledger()
|
| 116 |
|
| 117 |
+
def test_empty_database_url_raises(self, monkeypatch):
|
| 118 |
monkeypatch.setenv("DATABASE_URL", "")
|
| 119 |
+
with pytest.raises(RuntimeError, match="DATABASE_URL is required"):
|
| 120 |
+
make_ledger()
|
| 121 |
|
| 122 |
def test_explicit_url_builds_sqlalchemy_backend(self):
|
| 123 |
with tempfile.TemporaryDirectory() as tmpdir:
|
uv.lock
CHANGED
|
@@ -1627,6 +1627,10 @@ version = "0.1.0"
|
|
| 1627 |
source = { virtual = "." }
|
| 1628 |
dependencies = [
|
| 1629 |
{ name = "gradio" },
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1630 |
{ name = "modal" },
|
| 1631 |
{ name = "openai" },
|
| 1632 |
{ name = "opentelemetry-api" },
|
|
@@ -1637,6 +1641,7 @@ dependencies = [
|
|
| 1637 |
{ name = "pydantic" },
|
| 1638 |
{ name = "python-dotenv" },
|
| 1639 |
{ name = "pyyaml" },
|
|
|
|
| 1640 |
{ name = "sqlalchemy" },
|
| 1641 |
]
|
| 1642 |
|
|
@@ -1645,27 +1650,14 @@ dev = [
|
|
| 1645 |
{ name = "pytest" },
|
| 1646 |
{ name = "ruff" },
|
| 1647 |
]
|
| 1648 |
-
instructor = [
|
| 1649 |
-
{ name = "instructor" },
|
| 1650 |
-
]
|
| 1651 |
-
litellm = [
|
| 1652 |
-
{ name = "litellm" },
|
| 1653 |
-
]
|
| 1654 |
-
mcp = [
|
| 1655 |
-
{ name = "mcp" },
|
| 1656 |
-
]
|
| 1657 |
-
memory = [
|
| 1658 |
-
{ name = "mem0ai" },
|
| 1659 |
-
{ name = "sentence-transformers" },
|
| 1660 |
-
]
|
| 1661 |
|
| 1662 |
[package.metadata]
|
| 1663 |
requires-dist = [
|
| 1664 |
{ name = "gradio", specifier = ">=4.44.0" },
|
| 1665 |
-
{ name = "instructor",
|
| 1666 |
-
{ name = "litellm",
|
| 1667 |
-
{ name = "mcp",
|
| 1668 |
-
{ name = "mem0ai",
|
| 1669 |
{ name = "modal", specifier = ">=1.4.3" },
|
| 1670 |
{ name = "openai", specifier = ">=1.40.0" },
|
| 1671 |
{ name = "opentelemetry-api", specifier = ">=1.20.0" },
|
|
@@ -1678,10 +1670,10 @@ requires-dist = [
|
|
| 1678 |
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
| 1679 |
{ name = "pyyaml", specifier = ">=6.0" },
|
| 1680 |
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5.0" },
|
| 1681 |
-
{ name = "sentence-transformers",
|
| 1682 |
{ name = "sqlalchemy", specifier = ">=2.0" },
|
| 1683 |
]
|
| 1684 |
-
provides-extras = ["dev"
|
| 1685 |
|
| 1686 |
[[package]]
|
| 1687 |
name = "multidict"
|
|
|
|
| 1627 |
source = { virtual = "." }
|
| 1628 |
dependencies = [
|
| 1629 |
{ name = "gradio" },
|
| 1630 |
+
{ name = "instructor" },
|
| 1631 |
+
{ name = "litellm" },
|
| 1632 |
+
{ name = "mcp" },
|
| 1633 |
+
{ name = "mem0ai" },
|
| 1634 |
{ name = "modal" },
|
| 1635 |
{ name = "openai" },
|
| 1636 |
{ name = "opentelemetry-api" },
|
|
|
|
| 1641 |
{ name = "pydantic" },
|
| 1642 |
{ name = "python-dotenv" },
|
| 1643 |
{ name = "pyyaml" },
|
| 1644 |
+
{ name = "sentence-transformers" },
|
| 1645 |
{ name = "sqlalchemy" },
|
| 1646 |
]
|
| 1647 |
|
|
|
|
| 1650 |
{ name = "pytest" },
|
| 1651 |
{ name = "ruff" },
|
| 1652 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1653 |
|
| 1654 |
[package.metadata]
|
| 1655 |
requires-dist = [
|
| 1656 |
{ name = "gradio", specifier = ">=4.44.0" },
|
| 1657 |
+
{ name = "instructor", specifier = ">=1.5" },
|
| 1658 |
+
{ name = "litellm", specifier = ">=1.40" },
|
| 1659 |
+
{ name = "mcp", specifier = ">=1.0" },
|
| 1660 |
+
{ name = "mem0ai", specifier = ">=0.1" },
|
| 1661 |
{ name = "modal", specifier = ">=1.4.3" },
|
| 1662 |
{ name = "openai", specifier = ">=1.40.0" },
|
| 1663 |
{ name = "opentelemetry-api", specifier = ">=1.20.0" },
|
|
|
|
| 1670 |
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
| 1671 |
{ name = "pyyaml", specifier = ">=6.0" },
|
| 1672 |
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5.0" },
|
| 1673 |
+
{ name = "sentence-transformers", specifier = ">=3.0" },
|
| 1674 |
{ name = "sqlalchemy", specifier = ">=2.0" },
|
| 1675 |
]
|
| 1676 |
+
provides-extras = ["dev"]
|
| 1677 |
|
| 1678 |
[[package]]
|
| 1679 |
name = "multidict"
|