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 CHANGED
@@ -1,12 +1,12 @@
1
  # Copy to .env and fill in your values.
2
  #
3
- # With no live binding configured the app runs on a deterministic local stub —
4
- # fully offline, no network, NO API KEYS. That is the default, and it is fully
5
- # reproducible (ideal for demos and CI). To go live, pick one of two inference
6
- # backends (the Lab's §00 selector chooses per run): the small models you serve
7
- # yourself from `modal/` (see modal/README.md), or Hugging Face's serverless
8
- # Inference Providers (just an HF_TOKEN). Either is small-model-only (<=32B); there
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). When unset, the in-memory
58
- # ledger is used (fully offline). When set, the append-only ledger is persisted
59
- # through SQLAlchemy. Neon (managed Postgres) form:
60
  # DATABASE_URL=postgresql+psycopg://USER:PASSWORD@HOST/DB?sslmode=require
61
- # A local SQLite URL also works (e.g. to try the backend 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; fully
67
- # offline once the model is cached) install with `uv sync --extra memory`.
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 = auto (deterministic stub unless a live binding is configured
5
- # MODAL_WORKSPACE or MODAL_LLM_BASE_URL)
6
- # true = always the deterministic stub (reproducible demos / CI)
7
- # false = always live inference
 
 
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; "" when neither offline stub)
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
- - Offline (no API key) it serves a `DeterministicTinyModel` for *every* profile,
28
- so demos and tests are reproducible with no inference.
 
 
 
 
 
 
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 path keeps the green, mock-free test suite.
 
 
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
- **Env-gated, offline by default.** A small factory (`src/core/ledger_factory.py`,
39
- `make_ledger()`) selects the backend: `DATABASE_URL` set → `SqlAlchemyLedger`;
40
- unset the in-memory `Ledger`. With no `DATABASE_URL` the system never imports
41
- SQLAlchemy or a driver. SQLAlchemy is imported lazily inside the backend, so
42
- importing `src.core.*` does not require the library to be installed. The store
43
- deps (`sqlalchemy>=2.0`, `psycopg[binary]>=3` — the Neon driver) are **core
44
- dependencies** in `pyproject.toml`: the deployed app is configured with a real
45
- `DATABASE_URL` and must not silently degrade to a local store when the durable
46
- backend is missing, so the driver always ships rather than hiding behind an
47
- optional extra. (The lazy import is retained so `src.core.*` stays importable in
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
- - The offline path is the default and is import-clean: all existing tests pass
74
- with no database and no `store` extra installed.
 
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
- # Durable event store backend (ADR-0014). Core dependency: the app requires a
18
- # real DATABASE_URL and refuses to run without it. psycopg is the Neon driver.
 
 
 
 
 
 
 
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 — env-gated, offline by default.
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
- - ``DATABASE_URL`` set → :class:`SqlAlchemyLedger` (Postgres/Neon or any
8
- SQLAlchemy URL), the durable event store.
9
- - ``DATABASE_URL`` unset the in-memory :class:`Ledger`.
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 (the ``store``
33
- extra), so a copy-pasted Neon URL would fail with a missing-driver error.
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 configured ledger backend.
45
 
46
- *url* overrides ``DATABASE_URL`` (useful for tests/scripts). When neither is
47
- set, returns the in-memory ``Ledger``. ``SqlAlchemyLedger`` is imported lazily
48
- so the offline path does not require SQLAlchemy to be installed.
49
  """
50
  resolved = url or database_url()
51
  if not resolved:
52
- return Ledger()
 
 
 
 
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 (offline/specs)."""
 
 
 
 
 
 
 
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
- if self.models.offline is False:
205
- return ModelRouter(offline=False, specs=specs)
206
- # auto: detect credentials, but keep explicit specs for the live case
207
- router = ModelRouter.from_env()
208
- if not router.offline and specs:
209
- router.specs = specs
210
- return router
 
 
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
- Offline (no API key) the router serves a :class:`DeterministicTinyModel` for
13
- every profile, so demos and tests run with zero inference and full
14
- reproducibility. See ADR-0010.
 
 
 
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
- Offline (deterministic stub for every profile) unless a live API key is
162
- present, in which case each profile resolves to its concrete model via
163
- ``resolve_model`` plus the per-profile decoding defaults.
 
164
  """
165
- return cls(offline=not has_live_credentials())
 
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/offline wiring (no network, no creds).
2
 
3
- These tests pin the *selection* behaviour that hooks the Fishbowl UI to live
4
- services so it cannot silently regress:
 
5
 
6
- * Models — :meth:`ModelRouter.from_env` is offline unless a Modal binding is set
7
- (``MODAL_WORKSPACE`` or ``MODAL_LLM_BASE_URL``), via
8
- :func:`~src.models.openai_compat.has_live_credentials`.
9
- * Ledger — :func:`~src.core.ledger_factory.make_ledger` returns the in-memory
10
- ``Ledger`` with no ``DATABASE_URL`` and the ``SqlAlchemyLedger`` for a URL.
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), an in-memory SQLite
15
- URL, and optional-dep guards (``pytest.importorskip``) so the suite never touches a
16
- real network, a database server, or live credentials.
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: offline by default, live when a Modal binding is present ───────────
49
 
50
 
51
- class TestModelsOfflineByDefault:
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 test_base_url_makes_router_live(self, monkeypatch):
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
- # ── ledger: in-memory by default, SqlAlchemy-backed for a URL ──────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
 
86
  class TestLedgerSelection:
87
- def test_no_url_returns_in_memory_ledger(self):
88
- ledger = make_ledger()
89
- assert type(ledger) is Ledger
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
- def test_build_router_offline_without_binding(self, monkeypatch):
34
- # No Modal binding configured → the deterministic offline stub.
35
- for var in ("MODAL_WORKSPACE", "MODAL_LLM_BASE_URL", "OPENAI_API_KEY"):
 
36
  monkeypatch.delenv(var, raising=False)
37
  reg = default_registry()
38
- router = reg.build_router()
 
 
 
 
 
 
39
  assert isinstance(router, ModelRouter)
40
- assert router.offline is True
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 == 220 # 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,9 +133,9 @@ class TestModelRouterCatalogueEndpoint:
133
 
134
 
135
  class TestFromEnv:
136
- def test_offline_without_binding(self, monkeypatch):
137
- # No backend configured (no Modal binding, no HF token, no stray cloud key)
138
- # deterministic offline stub.
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 True
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 test_offline_returns_in_memory_ledger(self, monkeypatch):
114
  monkeypatch.delenv("DATABASE_URL", raising=False)
115
- ledger = make_ledger()
116
- assert type(ledger) is Ledger
117
 
118
- def test_empty_database_url_is_offline(self, monkeypatch):
119
  monkeypatch.setenv("DATABASE_URL", "")
120
- assert type(make_ledger()) is Ledger
 
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", marker = "extra == 'instructor'", specifier = ">=1.5" },
1666
- { name = "litellm", marker = "extra == 'litellm'", specifier = ">=1.40" },
1667
- { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0" },
1668
- { name = "mem0ai", marker = "extra == 'memory'", specifier = ">=0.1" },
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", marker = "extra == 'memory'", specifier = ">=3.0" },
1682
  { name = "sqlalchemy", specifier = ">=2.0" },
1683
  ]
1684
- provides-extras = ["dev", "litellm", "instructor", "mcp", "memory"]
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"