agharsallah Codex commited on
Commit
7df0a45
Β·
1 Parent(s): 98938c3

feat: LiteLLM gateway routing profiles to Modal-served small models

Browse files

Add LiteLLMProvider; ModelRouter binds each profile (tiny/fast/balanced/strong)
to its Modal vLLM endpoint (workspace env-templated, not hardcoded) and meters
real token + cost usage into the Governor, making hourly_budget_usd live. Env-
gated; offline keeps the deterministic stub. 212 passed, 1 skipped. ADR-0015.

Co-Authored-By: Codex <codex@openai.com>

.env.example CHANGED
@@ -1,7 +1,8 @@
1
  # Copy to .env and fill in your values.
2
- # Without OPENAI_API_KEY the app runs on a deterministic local stub.
 
3
 
4
- # Required for live model inference
5
  OPENAI_API_KEY=sk-...
6
 
7
  # Optional: point at any OpenAI-compatible endpoint
@@ -23,6 +24,20 @@ MODEL_NAME=gpt-4o-mini
23
  # MODEL_BALANCED=qwen2.5-14b-instruct
24
  # MODEL_STRONG=qwen2.5-32b-instruct
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  # Durable event store backend (ADR-0014). When unset, the system uses the
27
  # in-memory ledger (fully offline). When set, the append-only ledger is persisted
28
  # through SQLAlchemy. Neon (managed Postgres) form:
 
1
  # Copy to .env and fill in your values.
2
+ # With no live binding configured (neither OPENAI_API_KEY nor MODAL_WORKSPACE)
3
+ # the app runs on a deterministic local stub β€” fully offline, no network.
4
 
5
+ # Required for live model inference via a generic OpenAI-compatible key
6
  OPENAI_API_KEY=sk-...
7
 
8
  # Optional: point at any OpenAI-compatible endpoint
 
24
  # MODEL_BALANCED=qwen2.5-14b-instruct
25
  # MODEL_STRONG=qwen2.5-32b-instruct
26
 
27
+ # LiteLLM gateway β†’ models served on Modal (ADR-0015). Setting MODAL_WORKSPACE
28
+ # activates the live path: config/models.yaml templates each profile's endpoint
29
+ # URL as https://${MODAL_WORKSPACE}--<endpoint>.modal.run/v1 (so the workspace is
30
+ # never hard-coded), routes through LiteLLM, and meters real per-call cost into
31
+ # the Governor's hourly_budget_usd. MODAL_LLM_KEY is the endpoint key β€” a
32
+ # self-served vLLM endpoint accepts any token (default "EMPTY").
33
+ # MODAL_WORKSPACE=your-modal-workspace
34
+ # MODAL_LLM_KEY=EMPTY
35
+ # Alternatively, point a single OpenAI-compatible base URL (also activates live):
36
+ # MODAL_LLM_BASE_URL=https://your-workspace--gemma-4-12b.modal.run/v1
37
+ MODAL_WORKSPACE=
38
+ MODAL_LLM_KEY=
39
+ MODAL_LLM_BASE_URL=
40
+
41
  # Durable event store backend (ADR-0014). When unset, the system uses the
42
  # in-memory ledger (fully offline). When set, the append-only ledger is persisted
43
  # through SQLAlchemy. Neon (managed Postgres) form:
config/models.yaml CHANGED
@@ -1,29 +1,53 @@
1
- # Logical model profiles β†’ concrete small models.
2
  #
3
  # offline:
4
- # null = auto (deterministic stub unless OPENAI_API_KEY is set)
 
5
  # true = always the deterministic stub (reproducible demos / CI)
6
  # false = always live inference
7
  #
8
- # Every model here is ≀32B (the hackathon cap); `tiny` is ≀4B (Tiny Titan).
9
- # Swap any `model` to point a tier at a different small model β€” that is the
10
- # whole "small models, easily configurable" story in one file. Env vars
11
- # MODEL_TINY / MODEL_FAST / MODEL_BALANCED / MODEL_STRONG override at runtime.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  offline: null
13
  profiles:
14
  tiny:
15
- model: qwen2.5-3b-instruct
 
 
16
  temperature: 0.7
17
  max_tokens: 160
18
  fast:
19
- model: qwen2.5-7b-instruct
 
 
20
  temperature: 0.9
21
  max_tokens: 220
22
  balanced:
23
- model: qwen2.5-14b-instruct
 
 
24
  temperature: 0.8
25
  max_tokens: 320
26
  strong:
27
- model: qwen2.5-32b-instruct
 
 
28
  temperature: 0.6
29
  max_tokens: 480
 
1
+ # Logical model profiles β†’ concrete small models served on Modal.
2
  #
3
  # offline:
4
+ # null = auto (deterministic stub unless a live binding is configured β€”
5
+ # OPENAI_API_KEY, or MODAL_WORKSPACE/MODAL_LLM_BASE_URL for Modal)
6
  # true = always the deterministic stub (reproducible demos / CI)
7
  # false = always live inference
8
  #
9
+ # Live transport is the LiteLLM gateway (ADR-0015). Each profile binds to one of
10
+ # the OpenAI-compatible vLLM endpoints served on Modal (see modal/registry.py):
11
+ # tiny β†’ nemotron-3-nano-4b (≀4B, Tiny Titan)
12
+ # fast β†’ minicpm-4-1-8b
13
+ # balanced β†’ gemma-4-12b
14
+ # strong β†’ gemma-4-26b
15
+ #
16
+ # The LiteLLM model string for a custom OpenAI-compatible endpoint is
17
+ # `openai/<served_model_id>` (the HF repo id) with `base_url` pointing at the
18
+ # endpoint's /v1 URL. Modal serves each endpoint at
19
+ # https://<workspace>--<endpoint_name>.modal.run/v1
20
+ # so only the workspace is deploy-specific: it is templated from $MODAL_WORKSPACE
21
+ # and never hard-coded. $MODAL_LLM_KEY is the endpoint key (vLLM accepts any
22
+ # token, default "EMPTY"). Unset templates expand to "" β†’ the offline stub is
23
+ # used and these live bindings are ignored.
24
+ #
25
+ # Swap any `model` to point a tier at a different small model β€” that is the whole
26
+ # "small models, easily configurable" story in one file. Env vars MODEL_TINY /
27
+ # MODEL_FAST / MODEL_BALANCED / MODEL_STRONG still override the model name.
28
  offline: null
29
  profiles:
30
  tiny:
31
+ model: openai/nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16
32
+ base_url: https://${MODAL_WORKSPACE}--nemotron-3-nano-4b.modal.run/v1
33
+ api_key: ${MODAL_LLM_KEY}
34
  temperature: 0.7
35
  max_tokens: 160
36
  fast:
37
+ model: openai/openbmb/MiniCPM4.1-8B
38
+ base_url: https://${MODAL_WORKSPACE}--minicpm-4-1-8b.modal.run/v1
39
+ api_key: ${MODAL_LLM_KEY}
40
  temperature: 0.9
41
  max_tokens: 220
42
  balanced:
43
+ model: openai/google/gemma-4-12B
44
+ base_url: https://${MODAL_WORKSPACE}--gemma-4-12b.modal.run/v1
45
+ api_key: ${MODAL_LLM_KEY}
46
  temperature: 0.8
47
  max_tokens: 320
48
  strong:
49
+ model: openai/google/gemma-4-26B-A4B-it
50
+ base_url: https://${MODAL_WORKSPACE}--gemma-4-26b.modal.run/v1
51
+ api_key: ${MODAL_LLM_KEY}
52
  temperature: 0.6
53
  max_tokens: 480
docs/adr/0015-litellm-gateway-modal-models.md ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR-0015: LiteLLM Gateway for Modal-Served Small Models
2
+
3
+ ## Status
4
+
5
+ Accepted
6
+
7
+ ## Context
8
+
9
+ Per-agent model routing (ADR-0010) resolves each agent's logical profile
10
+ (`tiny`/`fast`/`balanced`/`strong`) to a concrete model behind an
11
+ OpenAI-compatible interface, and ADR-0005 added a Modal serving layer that exposes
12
+ small models (all ≀32B, with a ≀4B Tiny Titan tier) as autoscaling, vLLM-backed,
13
+ OpenAI-compatible HTTP endpoints. Until now the live transport was a hand-rolled
14
+ `openai` SDK call (`OpenAICompatProvider`) and there was no real cost signal: the
15
+ Governor's `hourly_budget_usd` (ADR-0007, ADR-0013) could only ever see `0.0`.
16
+
17
+ We want one gateway that (a) routes every profile through a single, idiomatic
18
+ call, (b) reaches the self-served Modal/vLLM endpoints without per-vendor
19
+ branching, and (c) reports the real per-call cost so spend caps become
20
+ enforceable β€” while keeping the offline path fully deterministic and free of any
21
+ new dependency.
22
+
23
+ ## Decision
24
+
25
+ Introduce a **LiteLLM gateway** as the live *transport*. This replaces how a model
26
+ is *called*, not the routing abstraction: `ModelRouter.for_profile(profile) ->
27
+ ModelProvider` and `ManifestAgent`'s usage are unchanged.
28
+
29
+ **Thin, standard provider.** `LiteLLMProvider(ModelProvider)`
30
+ (`src/models/litellm_provider.py`) issues a single
31
+ `litellm.completion(model=…, api_base=…, api_key=…, messages=[{system},{user}],
32
+ temperature=…, max_tokens=…)` call. The call is deliberately idiomatic so a later
33
+ layer can wrap it (e.g. `instructor.from_litellm(litellm.completion)`) without
34
+ fighting this code. `litellm` is imported lazily inside `complete()`, so importing
35
+ `src.models.*` (and `app`) never requires the package. On error it mirrors
36
+ `OpenAICompatProvider`: zero the usage and return a `"[model error: …]"` string.
37
+
38
+ **Profiles β†’ Modal endpoints.** Each profile binds to one served endpoint from
39
+ `modal/registry.py`:
40
+
41
+ | Profile | Modal endpoint | Served model id |
42
+ |---|---|---|
43
+ | `tiny` | `nemotron-3-nano-4b` | `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` |
44
+ | `fast` | `minicpm-4-1-8b` | `openbmb/MiniCPM4.1-8B` |
45
+ | `balanced` | `gemma-4-12b` | `google/gemma-4-12B` |
46
+ | `strong` | `gemma-4-26b` | `google/gemma-4-26B-A4B-it` |
47
+
48
+ For an OpenAI-compatible custom endpoint the LiteLLM model string is
49
+ `openai/<served_model_id>` with `api_base` set to the endpoint's `/v1` URL. A
50
+ self-served vLLM endpoint accepts any token, so the key defaults to the
51
+ conventional `"EMPTY"` when unset.
52
+
53
+ **Workspace is not hard-coded.** Modal serves each endpoint at a distinct
54
+ subdomain `https://<workspace>--<endpoint>.modal.run/v1`, so a single base URL
55
+ cannot address all four. `config/models.yaml` templates only the deploy-specific
56
+ workspace: `base_url: https://${MODAL_WORKSPACE}--<endpoint>.modal.run/v1` and
57
+ `api_key: ${MODAL_LLM_KEY}`. `Registry.from_dir()` expands these on load
58
+ (`_expand_env`); if any referenced var is unset the whole string collapses to `""`
59
+ (an incomplete binding is *not configured* rather than a broken half-URL) and a
60
+ validator nulls it. `ModelProfileConfig`/`ProfileSpec` gained `api_key` alongside
61
+ the existing `base_url`.
62
+
63
+ **Real cost β†’ Governor.** The provider reads cost from
64
+ `response._hidden_params["response_cost"]`, falling back to
65
+ `litellm.completion_cost(response)` β€” both guarded, so an unpriced/self-served
66
+ model simply yields `0.0` instead of raising. Cost is exposed on
67
+ `last_usage["cost_usd"]` (and `last_cost`); `ManifestAgent` carries it on its
68
+ `last_usage`, and the conductor passes it to `governor.record_call(tokens=…,
69
+ cost_usd=…)`. `hourly_budget_usd` is now a real spend cap on the live path.
70
+
71
+ **Env-gated, offline by default.** `has_live_credentials()` stays the single
72
+ online/offline decision and now also treats `MODAL_WORKSPACE` /
73
+ `MODAL_LLM_BASE_URL` as an activating signal (in addition to `OPENAI_API_KEY`).
74
+ With none set, the router serves `DeterministicTinyModel` for every profile, live
75
+ bindings are ignored, and cost is `0.0`. `litellm` is an optional `litellm` extra
76
+ in `pyproject.toml`.
77
+
78
+ ## Consequences
79
+
80
+ - A hosted deployment sets `MODAL_WORKSPACE` (and optionally `MODAL_LLM_KEY`) and
81
+ every profile routes through LiteLLM to its Modal endpoint, with real cost
82
+ metered into the Governor. Nothing else changes.
83
+ - The offline path is the default and import-clean: the full suite passes with no
84
+ credentials, no network, and `litellm` not installed.
85
+ - `OpenAICompatProvider` is retained for its role→system persona map (reused by the
86
+ gateway) and `has_live_credentials()`; it is no longer the live transport.
87
+ - The standard `litellm.completion(...)` shape leaves the door open for the
88
+ follow-up Instructor change to wrap the same client for structured output.
89
+ - Cost accuracy depends on LiteLLM's pricing database; self-served vLLM models are
90
+ unpriced and report `0.0`. Token caps (`max_total_tokens`) remain the budget
91
+ guard for those; attaching `custom_cost_per_token` per endpoint is a follow-up.
92
+ - A single `MODAL_LLM_BASE_URL` activates the live path but points only one URL;
93
+ multi-endpoint routing uses `MODAL_WORKSPACE` templating. Keeping both is
94
+ intentional (one-endpoint smoke tests vs. the full four-tier cast).
docs/architecture/model-routing.md CHANGED
@@ -23,30 +23,77 @@ manifest.model_profile ──► ModelRouter.for_profile(profile) ──►
23
 
24
  `ManifestAgent._complete()` calls `router.for_profile(self.manifest.model_profile)`
25
  every turn and records the provider's `last_usage` so the conductor can meter
26
- tokens into the Governor.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  ## Configuration
29
 
30
- `config/models.yaml` binds each profile to a concrete model + decoding:
 
 
31
 
32
  ```yaml
33
  offline: null # null=auto, true=stub everywhere, false=always live
34
  profiles:
35
- tiny: { model: qwen2.5-3b-instruct, temperature: 0.7, max_tokens: 160 }
36
- fast: { model: qwen2.5-7b-instruct, temperature: 0.9, max_tokens: 220 }
37
- balanced: { model: qwen2.5-14b-instruct, temperature: 0.8, max_tokens: 320 }
38
- strong: { model: qwen2.5-32b-instruct, temperature: 0.6, max_tokens: 480 }
 
 
 
39
  ```
40
 
41
- Runtime env overrides (highest priority): `MODEL_TINY`, `MODEL_FAST`,
42
- `MODEL_BALANCED`, `MODEL_STRONG`, then `MODEL_NAME` as a final fallback.
 
 
 
 
43
 
44
  ## Offline determinism
45
 
46
- With no `OPENAI_API_KEY`, the router serves a `DeterministicTinyModel` for every
47
- profile (variant tagged per profile). Demos and the entire test suite run with
48
- zero inference and full reproducibility β€” the offline/online decision is made
49
- once in `has_live_credentials()`.
 
 
50
 
51
  ## Mixing tiers in one cast
52
 
@@ -64,6 +111,9 @@ everything on the big model.
64
  ## Code
65
 
66
  - `src/models/router.py` β€” `ModelRouter`, `ProfileSpec`, `_PROFILE_DECODING`
 
67
  - `src/core/manifest.py` β€” `resolve_model()` (env β†’ default name resolution)
 
68
  - `src/models/provider.py` β€” `ModelProvider.last_usage`, `estimate_tokens()`
69
- - `src/models/openai_compat.py` β€” live provider, real usage capture
 
 
23
 
24
  `ManifestAgent._complete()` calls `router.for_profile(self.manifest.model_profile)`
25
  every turn and records the provider's `last_usage` so the conductor can meter
26
+ tokens β€” and, on the live path, real cost β€” into the Governor.
27
+
28
+ ## Transport: the LiteLLM gateway (live path)
29
+
30
+ The router resolves *which* model; the provider is *how* it is called. On the
31
+ live path that transport is the **LiteLLM gateway** (`LiteLLMProvider`, ADR-0015):
32
+ a single idiomatic `litellm.completion(...)` call routes every profile, including
33
+ self-served OpenAI-compatible endpoints. The routing abstraction is unchanged β€”
34
+ only the transport moved off a hand-rolled SDK call.
35
+
36
+ ```
37
+ ModelRouter._build(profile)
38
+ offline β†’ DeterministicTinyModel(variant="stub:<profile>")
39
+ live β†’ LiteLLMProvider(model="openai/<hf id>", api_base=<modal url>, …)
40
+ ```
41
+
42
+ Profiles map to the OpenAI-compatible vLLM endpoints served on Modal
43
+ (`modal/registry.py`):
44
+
45
+ | Profile | Modal endpoint | Served model id |
46
+ |---|---|---|
47
+ | `tiny` | `nemotron-3-nano-4b` | `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` |
48
+ | `fast` | `minicpm-4-1-8b` | `openbmb/MiniCPM4.1-8B` |
49
+ | `balanced` | `gemma-4-12b` | `google/gemma-4-12B` |
50
+ | `strong` | `gemma-4-26b` | `google/gemma-4-26B-A4B-it` |
51
+
52
+ The LiteLLM model string for an OpenAI-compatible custom endpoint is
53
+ `openai/<served_model_id>` with `api_base` pointing at the endpoint's `/v1` URL.
54
+
55
+ ### Real cost β†’ Governor
56
+
57
+ LiteLLM prices each call (`response._hidden_params["response_cost"]`, falling back
58
+ to `litellm.completion_cost(response)` β€” both guarded, so an unpriced self-served
59
+ model yields `0.0`). The provider exposes it on `last_usage["cost_usd"]` (and
60
+ `last_cost`); `ManifestAgent` carries it, and the conductor passes it to
61
+ `governor.record_call(tokens=…, cost_usd=…)`. This makes `hourly_budget_usd` a real
62
+ spend cap on the live path. Offline cost is always `0.0`.
63
 
64
  ## Configuration
65
 
66
+ `config/models.yaml` binds each profile to a concrete model + endpoint + decoding.
67
+ Only the Modal workspace is deploy-specific, so it is templated from
68
+ `$MODAL_WORKSPACE` and never hard-coded; `$MODAL_LLM_KEY` is the endpoint key:
69
 
70
  ```yaml
71
  offline: null # null=auto, true=stub everywhere, false=always live
72
  profiles:
73
+ tiny:
74
+ model: openai/nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16
75
+ base_url: https://${MODAL_WORKSPACE}--nemotron-3-nano-4b.modal.run/v1
76
+ api_key: ${MODAL_LLM_KEY}
77
+ temperature: 0.7
78
+ max_tokens: 160
79
+ # fast / balanced / strong follow the same shape (see the file)
80
  ```
81
 
82
+ `Registry.from_dir()` expands `${VAR}` references when it loads the file. If any
83
+ referenced var is unset, that string collapses to `""` (a binding built from a
84
+ missing workspace is *not configured*, not a half-templated URL) and the validator
85
+ nulls it. Runtime env overrides for the model name (highest priority): `MODEL_TINY`,
86
+ `MODEL_FAST`, `MODEL_BALANCED`, `MODEL_STRONG`, then `MODEL_NAME` (these feed the
87
+ `from_env` default path; explicit `models.yaml` specs win on the registry path).
88
 
89
  ## Offline determinism
90
 
91
+ With no live binding configured β€” neither `OPENAI_API_KEY` nor
92
+ `MODAL_WORKSPACE`/`MODAL_LLM_BASE_URL` β€” the router serves a
93
+ `DeterministicTinyModel` for every profile (variant tagged per profile). Demos and
94
+ the entire test suite run with zero inference and full reproducibility β€” the
95
+ offline/online decision is made once in `has_live_credentials()`, and `litellm` is
96
+ imported lazily so it need not be installed at all offline.
97
 
98
  ## Mixing tiers in one cast
99
 
 
111
  ## Code
112
 
113
  - `src/models/router.py` β€” `ModelRouter`, `ProfileSpec`, `_PROFILE_DECODING`
114
+ - `src/models/litellm_provider.py` β€” `LiteLLMProvider` (live transport, real cost)
115
  - `src/core/manifest.py` β€” `resolve_model()` (env β†’ default name resolution)
116
+ - `src/core/registry.py` β€” `build_router()`, `_expand_env()` (YAML env templating)
117
  - `src/models/provider.py` β€” `ModelProvider.last_usage`, `estimate_tokens()`
118
+ - `src/models/openai_compat.py` β€” `has_live_credentials()`, roleοΏ½οΏ½system personas
119
+ - `modal/registry.py` β€” the served endpoints each profile points at
pyproject.toml CHANGED
@@ -23,6 +23,13 @@ store = [
23
  "sqlalchemy>=2.0",
24
  "psycopg[binary]>=3",
25
  ]
 
 
 
 
 
 
 
26
 
27
  [tool.ruff]
28
  line-length = 120
 
23
  "sqlalchemy>=2.0",
24
  "psycopg[binary]>=3",
25
  ]
26
+ # Model gateway (ADR-0015). Optional: routes live profiles through LiteLLM to the
27
+ # OpenAI-compatible models served on Modal, with real per-call cost metering. The
28
+ # system runs fully offline on the deterministic stub without this installed;
29
+ # litellm is imported lazily so importing src.models.* never requires it.
30
+ litellm = [
31
+ "litellm>=1.40",
32
+ ]
33
 
34
  [tool.ruff]
35
  line-length = 120
src/core/conductor.py CHANGED
@@ -160,8 +160,10 @@ class Conductor:
160
  projection=projection,
161
  recent_events=self.ledger.events,
162
  )
163
- tokens = int(getattr(agent, "last_usage", {}).get("total_tokens", 0) or 0)
164
- self.governor.record_call(tokens=tokens)
 
 
165
  self._append(event)
166
  projection.apply(event)
167
 
 
160
  projection=projection,
161
  recent_events=self.ledger.events,
162
  )
163
+ usage = getattr(agent, "last_usage", {})
164
+ tokens = int(usage.get("total_tokens", 0) or 0)
165
+ cost_usd = float(usage.get("cost_usd", 0.0) or 0.0)
166
+ self.governor.record_call(tokens=tokens, cost_usd=cost_usd)
167
  self._append(event)
168
  projection.apply(event)
169
 
src/core/config.py CHANGED
@@ -30,9 +30,26 @@ class ModelProfileConfig(BaseModel):
30
 
31
  model: str
32
  base_url: str | None = None
 
 
 
 
 
 
 
33
  temperature: float = 0.8
34
  max_tokens: int = 256
35
 
 
 
 
 
 
 
 
 
 
 
36
 
37
  class ModelsConfig(BaseModel):
38
  model_config = ConfigDict(extra="forbid")
 
30
 
31
  model: str
32
  base_url: str | None = None
33
+ """OpenAI-compatible endpoint URL (ends in /v1). Env-templatable in YAML via
34
+ ``${MODAL_LLM_BASE_URL}`` so the Modal workspace is never hard-coded."""
35
+
36
+ api_key: str | None = None
37
+ """Key for the endpoint (env-templatable, e.g. ``${MODAL_LLM_KEY}``). vLLM
38
+ accepts any token unless the server enforces one."""
39
+
40
  temperature: float = 0.8
41
  max_tokens: int = 256
42
 
43
+ @model_validator(mode="after")
44
+ def _blank_to_none(self) -> "ModelProfileConfig":
45
+ # An unset ``${VAR}`` template expands to "" (see registry._expand_env);
46
+ # normalise empty bindings back to None so the live transport omits them.
47
+ if not self.base_url:
48
+ self.base_url = None
49
+ if not self.api_key:
50
+ self.api_key = None
51
+ return self
52
+
53
 
54
  class ModelsConfig(BaseModel):
55
  model_config = ConfigDict(extra="forbid")
src/core/registry.py CHANGED
@@ -14,6 +14,7 @@ their manifest.
14
  from __future__ import annotations
15
 
16
  import os
 
17
  from dataclasses import dataclass, field
18
  from pathlib import Path
19
 
@@ -31,6 +32,32 @@ _REPO_ROOT = Path(__file__).resolve().parents[2]
31
  # alternate deployment point the registry at a different config tree.
32
  DEFAULT_CONFIG_DIR = Path(os.getenv("MAL_CONFIG_DIR") or _REPO_ROOT / "config")
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  # ── handler registry (behaviour bindings) ────────────────────────────────────────
35
 
36
  HANDLERS: dict[str, type[ManifestAgent]] = {}
@@ -82,7 +109,8 @@ class Registry:
82
  models = ModelsConfig()
83
  models_file = root / "models.yaml"
84
  if models_file.is_file():
85
- models = ModelsConfig.model_validate(yaml.safe_load(models_file.read_text()) or {})
 
86
 
87
  return cls(agents=agents, scenarios=scenarios, models=models)
88
 
 
14
  from __future__ import annotations
15
 
16
  import os
17
+ import re
18
  from dataclasses import dataclass, field
19
  from pathlib import Path
20
 
 
32
  # alternate deployment point the registry at a different config tree.
33
  DEFAULT_CONFIG_DIR = Path(os.getenv("MAL_CONFIG_DIR") or _REPO_ROOT / "config")
34
 
35
+
36
+ _ENV_REF = re.compile(r"\$\{(\w+)\}|\$(\w+)")
37
+
38
+
39
+ def _expand_env(value):
40
+ """Recursively expand ``$VAR`` / ``${VAR}`` in a loaded-config tree.
41
+
42
+ Lets ``config/models.yaml`` point profiles at a Modal endpoint without
43
+ hard-coding the workspace URL or key β€” e.g.
44
+ ``base_url: https://${MODAL_WORKSPACE}--<endpoint>.modal.run/v1``.
45
+
46
+ If *any* referenced var in a string is unset/empty, the whole string collapses
47
+ to ``""`` β€” a binding built from a missing workspace is simply *not configured*
48
+ rather than a half-templated, broken URL. The validator then nulls it, and the
49
+ offline path ignores live bindings entirely."""
50
+ if isinstance(value, str):
51
+ refs = _ENV_REF.findall(value)
52
+ if refs and any(not os.getenv(g1 or g2, "") for g1, g2 in refs):
53
+ return ""
54
+ return _ENV_REF.sub(lambda m: os.getenv(m.group(1) or m.group(2), ""), value)
55
+ if isinstance(value, dict):
56
+ return {k: _expand_env(v) for k, v in value.items()}
57
+ if isinstance(value, list):
58
+ return [_expand_env(v) for v in value]
59
+ return value
60
+
61
  # ── handler registry (behaviour bindings) ────────────────────────────────────────
62
 
63
  HANDLERS: dict[str, type[ManifestAgent]] = {}
 
109
  models = ModelsConfig()
110
  models_file = root / "models.yaml"
111
  if models_file.is_file():
112
+ raw_models = _expand_env(yaml.safe_load(models_file.read_text()) or {})
113
+ models = ModelsConfig.model_validate(raw_models)
114
 
115
  return cls(agents=agents, scenarios=scenarios, models=models)
116
 
src/models/litellm_provider.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LiteLLM-backed provider β€” one gateway, every logical profile.
2
+
3
+ This is the *transport* the :class:`~src.models.router.ModelRouter` uses on the
4
+ live path: it replaces hand-rolled per-vendor SDK calls with a single idiomatic
5
+ ``litellm.completion(...)`` call. Routing (profile β†’ concrete model + endpoint)
6
+ is unchanged and still lives in the router; this class only knows how to *call* a
7
+ model and report what it cost.
8
+
9
+ Two things it adds over the plain OpenAI-compatible provider:
10
+
11
+ * **Real cost.** LiteLLM prices the call from its model database, so the
12
+ Governor's ``hourly_budget_usd`` becomes real on the live path. Cost is
13
+ exposed on ``last_usage["cost_usd"]`` (and ``last_cost``); offline it is 0.
14
+ * **One model string for any endpoint.** An OpenAI-compatible custom endpoint
15
+ (the Modal/vLLM servers in ``modal/``) is reached with the LiteLLM model
16
+ string ``openai/<served_model_id>`` plus an ``api_base`` β€” no per-vendor
17
+ branching.
18
+
19
+ ``litellm`` is imported lazily so ``import src.models.*`` (and ``import app``)
20
+ work with the package not installed; offline never touches this class. The call
21
+ is kept thin and standard so a later layer can wrap it (e.g.
22
+ ``instructor.from_litellm(litellm.completion)``) without fighting this code.
23
+ See ADR-0015.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass, field
28
+
29
+ from src.models.openai_compat import OpenAICompatProvider
30
+ from src.models.provider import ModelProvider
31
+
32
+
33
+ @dataclass
34
+ class LiteLLMProvider(ModelProvider):
35
+ """Route one logical profile through the LiteLLM gateway.
36
+
37
+ ``model`` is a LiteLLM model string. For an OpenAI-compatible custom
38
+ endpoint (Modal/vLLM) it is ``openai/<served_model_id>`` and ``api_base``
39
+ points at the endpoint's ``/v1`` URL. Decoding (``temperature`` /
40
+ ``max_tokens``) and the binding come from the router's per-profile spec.
41
+ """
42
+
43
+ model: str
44
+ api_base: str | None = None
45
+ api_key: str | None = None
46
+ temperature: float = 0.8
47
+ max_tokens: int = 256
48
+ _last_usage: dict = field(default_factory=dict, init=False, repr=False)
49
+ _last_cost: float = field(default=0.0, init=False, repr=False)
50
+
51
+ def complete(self, role: str, prompt: str) -> str:
52
+ from src.models.provider import estimate_tokens
53
+
54
+ try:
55
+ import litellm
56
+ except ImportError as exc: # pragma: no cover - exercised only when unset
57
+ raise ImportError(
58
+ "litellm package is required for LiteLLMProvider. "
59
+ "Install it with: uv pip install litellm"
60
+ ) from exc
61
+
62
+ system = OpenAICompatProvider._system_for_role(role)
63
+ # A self-served vLLM endpoint accepts any token; default to the conventional
64
+ # placeholder so a configured custom endpoint never trips on a missing key.
65
+ api_key = self.api_key or ("EMPTY" if self.api_base else None)
66
+ try:
67
+ response = litellm.completion(
68
+ model=self.model,
69
+ api_base=self.api_base,
70
+ api_key=api_key,
71
+ messages=[
72
+ {"role": "system", "content": system},
73
+ {"role": "user", "content": prompt},
74
+ ],
75
+ temperature=self.temperature,
76
+ max_tokens=self.max_tokens,
77
+ )
78
+ text = (response.choices[0].message.content or "").strip()
79
+ usage = getattr(response, "usage", None)
80
+ if usage is not None:
81
+ prompt_tokens = int(getattr(usage, "prompt_tokens", 0) or 0)
82
+ completion_tokens = int(getattr(usage, "completion_tokens", 0) or 0)
83
+ total_tokens = int(getattr(usage, "total_tokens", 0) or 0) or (
84
+ prompt_tokens + completion_tokens
85
+ )
86
+ else:
87
+ prompt_tokens, completion_tokens = estimate_tokens(prompt), estimate_tokens(text)
88
+ total_tokens = prompt_tokens + completion_tokens
89
+ cost = self._extract_cost(litellm, response)
90
+ self._last_cost = cost
91
+ self._last_usage = {
92
+ "prompt_tokens": prompt_tokens,
93
+ "completion_tokens": completion_tokens,
94
+ "total_tokens": total_tokens,
95
+ "cost_usd": cost,
96
+ }
97
+ return text
98
+ except Exception as exc:
99
+ self._last_cost = 0.0
100
+ self._last_usage = {
101
+ "prompt_tokens": 0,
102
+ "completion_tokens": 0,
103
+ "total_tokens": 0,
104
+ "cost_usd": 0.0,
105
+ }
106
+ return f"[model error: {exc}]"
107
+
108
+ @property
109
+ def last_cost(self) -> float:
110
+ """Metered USD cost of the most recent :meth:`complete` call (0.0 offline)."""
111
+ return self._last_cost
112
+
113
+ @staticmethod
114
+ def _extract_cost(litellm, response) -> float:
115
+ """Best-effort USD cost for *response*; 0.0 if the model is unpriced.
116
+
117
+ Prefers the value LiteLLM already attached during the call
118
+ (``_hidden_params["response_cost"]``); falls back to pricing the response
119
+ directly. Both paths are guarded β€” an unknown/custom model (e.g. a
120
+ self-served vLLM endpoint) simply yields 0.0 rather than raising.
121
+ """
122
+ hidden = getattr(response, "_hidden_params", None)
123
+ if isinstance(hidden, dict):
124
+ cost = hidden.get("response_cost")
125
+ if isinstance(cost, (int, float)):
126
+ return float(cost)
127
+ try:
128
+ cost = litellm.completion_cost(completion_response=response)
129
+ return float(cost or 0.0)
130
+ except Exception:
131
+ return 0.0
src/models/openai_compat.py CHANGED
@@ -120,13 +120,23 @@ class OpenAICompatProvider(ModelProvider):
120
 
121
 
122
  def has_live_credentials() -> bool:
123
- """True when a usable API key is configured for live inference.
124
 
125
  Single source of truth for the online/offline decision, shared by
126
- ``build_from_env`` and the ModelRouter so they never disagree.
 
 
 
 
 
 
 
 
127
  """
128
  api_key = os.getenv("OPENAI_API_KEY", "")
129
- return bool(api_key) and api_key not in ("sk-stub", "your-key-here")
 
 
130
 
131
 
132
  def build_from_env() -> ModelProvider:
 
120
 
121
 
122
  def has_live_credentials() -> bool:
123
+ """True when a usable model binding is configured for live inference.
124
 
125
  Single source of truth for the online/offline decision, shared by
126
+ ``build_from_env`` and the ModelRouter so they never disagree. Two ways to go
127
+ live, either is sufficient:
128
+
129
+ * ``OPENAI_API_KEY`` β€” a generic OpenAI-compatible key; or
130
+ * ``MODAL_WORKSPACE`` / ``MODAL_LLM_BASE_URL`` β€” the binding for the models
131
+ served on Modal (ADR-0015): the workspace templates each profile's
132
+ endpoint URL in ``config/models.yaml``. The gateway reaches those with
133
+ ``MODAL_LLM_KEY`` (default ``"EMPTY"``), so the workspace/base-url is the
134
+ activating signal.
135
  """
136
  api_key = os.getenv("OPENAI_API_KEY", "")
137
+ if bool(api_key) and api_key not in ("sk-stub", "your-key-here"):
138
+ return True
139
+ return bool(os.getenv("MODAL_WORKSPACE", "") or os.getenv("MODAL_LLM_BASE_URL", ""))
140
 
141
 
142
  def build_from_env() -> ModelProvider:
src/models/router.py CHANGED
@@ -12,13 +12,18 @@ This is the single place per-agent model selection happens, so:
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
  from __future__ import annotations
17
 
18
  from dataclasses import dataclass, field
19
 
20
  from src.core.manifest import ModelProfile, resolve_model
21
- from src.models.openai_compat import OpenAICompatProvider, has_live_credentials
22
  from src.models.provider import DeterministicTinyModel, ModelProvider
23
 
24
  # Decoding defaults per profile. Smaller models stay cooler and shorter; the
@@ -37,6 +42,7 @@ class ProfileSpec:
37
 
38
  model: str
39
  base_url: str | None = None
 
40
  temperature: float = 0.8
41
  max_tokens: int = 256
42
 
@@ -77,10 +83,15 @@ class ModelRouter:
77
  def _build(self, profile: str) -> ModelProvider:
78
  if self.offline:
79
  return DeterministicTinyModel(variant=f"stub:{profile}")
 
 
 
 
80
  spec = self._spec_for(profile)
81
- return OpenAICompatProvider(
82
  model=spec.model,
83
- base_url=spec.base_url,
 
84
  temperature=spec.temperature,
85
  max_tokens=spec.max_tokens,
86
  )
 
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
18
+ ``modal/`` and the gateway reports real per-call cost into the Governor. The
19
+ routing abstraction here is unchanged β€” only how a model is *called* moved.
20
  """
21
  from __future__ import annotations
22
 
23
  from dataclasses import dataclass, field
24
 
25
  from src.core.manifest import ModelProfile, resolve_model
26
+ from src.models.openai_compat import has_live_credentials
27
  from src.models.provider import DeterministicTinyModel, ModelProvider
28
 
29
  # Decoding defaults per profile. Smaller models stay cooler and shorter; the
 
42
 
43
  model: str
44
  base_url: str | None = None
45
+ api_key: str | None = None
46
  temperature: float = 0.8
47
  max_tokens: int = 256
48
 
 
83
  def _build(self, profile: str) -> ModelProvider:
84
  if self.offline:
85
  return DeterministicTinyModel(variant=f"stub:{profile}")
86
+ # Live transport is the LiteLLM gateway (ADR-0015). Lazy-import keeps the
87
+ # offline path free of the dependency.
88
+ from src.models.litellm_provider import LiteLLMProvider
89
+
90
  spec = self._spec_for(profile)
91
+ return LiteLLMProvider(
92
  model=spec.model,
93
+ api_base=spec.base_url,
94
+ api_key=spec.api_key,
95
  temperature=spec.temperature,
96
  max_tokens=spec.max_tokens,
97
  )
tests/test_conductor.py CHANGED
@@ -2,6 +2,10 @@ from __future__ import annotations
2
 
3
 
4
  from src.core.conductor import Conductor
 
 
 
 
5
  from src.scenarios.thousand_token_wood import build_scenario
6
 
7
 
@@ -90,3 +94,39 @@ class TestConductorProjection:
90
  c.reset("the wood wakes")
91
  proj = c.projection
92
  assert proj.seed == "the wood wakes" or "the wood wakes" in proj.current_scene
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
 
4
  from src.core.conductor import Conductor
5
+ from src.core.events import Event
6
+ from src.core.governor import Governor
7
+ from src.core.manifest import AgentManifest, ScheduleConfig
8
+ from src.scenarios.base import Scenario
9
  from src.scenarios.thousand_token_wood import build_scenario
10
 
11
 
 
94
  c.reset("the wood wakes")
95
  proj = c.projection
96
  assert proj.seed == "the wood wakes" or "the wood wakes" in proj.current_scene
97
+
98
+
99
+ class _CostingAgent:
100
+ """Minimal agent that reports a per-call cost β€” stands in for the live gateway."""
101
+
102
+ manifest = AgentManifest(
103
+ name="coster",
104
+ persona="p",
105
+ may_emit=["world.observed"],
106
+ schedule=ScheduleConfig(tick_every=1),
107
+ )
108
+
109
+ def __init__(self) -> None:
110
+ self.last_usage = {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15, "cost_usd": 0.002}
111
+
112
+ def act(self, run_id, turn, projection, recent_events) -> Event:
113
+ return Event(run_id=run_id, turn=turn, kind="world.observed", actor="coster", payload={"text": "x"})
114
+
115
+
116
+ class TestConductorCostMetering:
117
+ def test_live_cost_reaches_governor(self):
118
+ # On the live path the agent carries real cost on last_usage; the conductor
119
+ # must plumb it into the Governor so hourly_budget_usd is enforceable.
120
+ scenario = Scenario(name="s", default_seed="seed", agents=(_CostingAgent(),))
121
+ c = Conductor(scenario=scenario, governor=Governor())
122
+ c.reset("seed")
123
+ c.step()
124
+ assert c.governor.stats["spend_usd"] > 0
125
+ assert c.governor.stats["total_tokens"] >= 15
126
+
127
+ def test_offline_cost_stays_zero(self):
128
+ # The deterministic stub reports no cost; spend must remain 0.
129
+ c = _conductor()
130
+ c.reset("seed")
131
+ c.step()
132
+ assert c.governor.stats["spend_usd"] == 0.0
tests/test_litellm_provider.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LiteLLM gateway tests β€” fully offline, litellm.completion monkeypatched.
2
+
3
+ No network and no real credentials: a fake ``litellm`` module (and a fake
4
+ response with ``.usage`` and a cost hook) is injected so we can assert the
5
+ provider returns the text and captures tokens + real cost, and that the router
6
+ builds a :class:`LiteLLMProvider` when live and the deterministic stub offline.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ import types
12
+ from dataclasses import dataclass
13
+
14
+ import pytest
15
+
16
+ from src.models.litellm_provider import LiteLLMProvider
17
+ from src.models.provider import DeterministicTinyModel
18
+ from src.models.router import ModelRouter, ProfileSpec
19
+
20
+
21
+ # ── fake litellm response objects ────────────────────────────────────────────
22
+
23
+
24
+ @dataclass
25
+ class _FakeUsage:
26
+ prompt_tokens: int = 11
27
+ completion_tokens: int = 7
28
+ total_tokens: int = 18
29
+
30
+
31
+ class _FakeMessage:
32
+ def __init__(self, content: str) -> None:
33
+ self.content = content
34
+
35
+
36
+ class _FakeChoice:
37
+ def __init__(self, content: str) -> None:
38
+ self.message = _FakeMessage(content)
39
+
40
+
41
+ class _FakeResponse:
42
+ def __init__(self, content: str, *, hidden_cost: float | None = None) -> None:
43
+ self.choices = [_FakeChoice(content)]
44
+ self.usage = _FakeUsage()
45
+ self._hidden_params = {} if hidden_cost is None else {"response_cost": hidden_cost}
46
+
47
+
48
+ def _install_fake_litellm(monkeypatch, *, response, cost_value=0.0, record=None):
49
+ """Inject a fake ``litellm`` module exposing completion + completion_cost."""
50
+ fake = types.ModuleType("litellm")
51
+
52
+ def _completion(**kwargs):
53
+ if record is not None:
54
+ record.update(kwargs)
55
+ if isinstance(response, Exception):
56
+ raise response
57
+ return response
58
+
59
+ def _completion_cost(completion_response=None, **_kwargs):
60
+ return cost_value
61
+
62
+ fake.completion = _completion
63
+ fake.completion_cost = _completion_cost
64
+ monkeypatch.setitem(sys.modules, "litellm", fake)
65
+ return fake
66
+
67
+
68
+ # ── provider ─────────────────────────────────────────────────────────────────
69
+
70
+
71
+ class TestLiteLLMProviderComplete:
72
+ def test_returns_text_and_captures_usage(self, monkeypatch):
73
+ _install_fake_litellm(monkeypatch, response=_FakeResponse("a mossy booth"), cost_value=0.0)
74
+ provider = LiteLLMProvider(model="openai/some/model", api_base="https://x/v1")
75
+ out = provider.complete("scene-whisperer", "grow the wood")
76
+ assert out == "a mossy booth"
77
+ assert provider.last_usage["prompt_tokens"] == 11
78
+ assert provider.last_usage["completion_tokens"] == 7
79
+ assert provider.last_usage["total_tokens"] == 18
80
+
81
+ def test_captures_cost_from_completion_cost(self, monkeypatch):
82
+ _install_fake_litellm(monkeypatch, response=_FakeResponse("hi"), cost_value=0.0123)
83
+ provider = LiteLLMProvider(model="openai/some/model", api_base="https://x/v1")
84
+ provider.complete("echo", "drop a pebble")
85
+ assert provider.last_usage["cost_usd"] == pytest.approx(0.0123)
86
+ assert provider.last_cost == pytest.approx(0.0123)
87
+
88
+ def test_prefers_hidden_params_cost(self, monkeypatch):
89
+ # When LiteLLM already attached a cost, use it without re-pricing.
90
+ _install_fake_litellm(
91
+ monkeypatch, response=_FakeResponse("hi", hidden_cost=0.05), cost_value=999.0
92
+ )
93
+ provider = LiteLLMProvider(model="openai/some/model", api_base="https://x/v1")
94
+ provider.complete("echo", "drop a pebble")
95
+ assert provider.last_usage["cost_usd"] == pytest.approx(0.05)
96
+
97
+ def test_calls_openai_style_for_custom_endpoint(self, monkeypatch):
98
+ record: dict = {}
99
+ _install_fake_litellm(monkeypatch, response=_FakeResponse("ok"), record=record)
100
+ provider = LiteLLMProvider(
101
+ model="openai/google/gemma-4-12B",
102
+ api_base="https://ws--gemma-4-12b.modal.run/v1",
103
+ api_key="EMPTY",
104
+ temperature=0.3,
105
+ max_tokens=99,
106
+ )
107
+ provider.complete("seedkeeper", "observe")
108
+ assert record["model"] == "openai/google/gemma-4-12B"
109
+ assert record["api_base"] == "https://ws--gemma-4-12b.modal.run/v1"
110
+ assert record["api_key"] == "EMPTY"
111
+ assert record["temperature"] == 0.3
112
+ assert record["max_tokens"] == 99
113
+ # Two messages: a role-derived system prompt, then the user prompt.
114
+ roles = [m["role"] for m in record["messages"]]
115
+ assert roles == ["system", "user"]
116
+ assert record["messages"][1]["content"] == "observe"
117
+
118
+ def test_defaults_api_key_for_custom_endpoint(self, monkeypatch):
119
+ record: dict = {}
120
+ _install_fake_litellm(monkeypatch, response=_FakeResponse("ok"), record=record)
121
+ provider = LiteLLMProvider(model="openai/m", api_base="https://x/v1") # no api_key
122
+ provider.complete("echo", "x")
123
+ assert record["api_key"] == "EMPTY"
124
+
125
+ def test_error_returns_marker_and_zeroes_usage(self, monkeypatch):
126
+ _install_fake_litellm(monkeypatch, response=RuntimeError("boom"))
127
+ provider = LiteLLMProvider(model="openai/m", api_base="https://x/v1")
128
+ out = provider.complete("echo", "x")
129
+ assert out.startswith("[model error:")
130
+ assert provider.last_usage["total_tokens"] == 0
131
+ assert provider.last_usage["cost_usd"] == 0.0
132
+ assert provider.last_cost == 0.0
133
+
134
+
135
+ # ── router integration ───────────────────────────────────────────────────────
136
+
137
+
138
+ class TestRouterBuildsGateway:
139
+ def test_live_profile_builds_litellm_provider(self):
140
+ router = ModelRouter(
141
+ offline=False,
142
+ specs={
143
+ "fast": ProfileSpec(
144
+ model="openai/openbmb/MiniCPM4.1-8B",
145
+ base_url="https://ws--minicpm-4-1-8b.modal.run/v1",
146
+ api_key="EMPTY",
147
+ )
148
+ },
149
+ )
150
+ provider = router.for_profile("fast")
151
+ assert isinstance(provider, LiteLLMProvider)
152
+ assert provider.model == "openai/openbmb/MiniCPM4.1-8B"
153
+ assert provider.api_base == "https://ws--minicpm-4-1-8b.modal.run/v1"
154
+
155
+ def test_offline_builds_deterministic_stub(self):
156
+ router = ModelRouter(offline=True)
157
+ assert isinstance(router.for_profile("fast"), DeterministicTinyModel)
158
+
159
+ def test_offline_usage_has_no_cost(self):
160
+ # The offline stub never reports cost; the conductor reads 0.0 for it.
161
+ router = ModelRouter(offline=True)
162
+ provider = router.for_profile("tiny")
163
+ provider.complete("scene-whisperer", "grow")
164
+ assert "cost_usd" not in provider.last_usage
tests/test_router.py CHANGED
@@ -1,6 +1,6 @@
1
  from __future__ import annotations
2
 
3
- from src.models.openai_compat import OpenAICompatProvider
4
  from src.models.provider import DeterministicTinyModel, ModelProvider, estimate_tokens
5
  from src.models.router import ModelRouter, ProfileSpec
6
 
@@ -45,11 +45,21 @@ class TestModelRouterOnline:
45
  def test_explicit_spec_used(self):
46
  router = ModelRouter(
47
  offline=False,
48
- specs={"tiny": ProfileSpec(model="qwen2.5-3b-instruct", temperature=0.5, max_tokens=128)},
 
 
 
 
 
 
 
 
49
  )
50
  provider = router.for_profile("tiny")
51
- assert isinstance(provider, OpenAICompatProvider)
52
- assert provider.model == "qwen2.5-3b-instruct"
 
 
53
  assert provider.temperature == 0.5
54
  assert provider.max_tokens == 128
55
 
 
1
  from __future__ import annotations
2
 
3
+ from src.models.litellm_provider import LiteLLMProvider
4
  from src.models.provider import DeterministicTinyModel, ModelProvider, estimate_tokens
5
  from src.models.router import ModelRouter, ProfileSpec
6
 
 
45
  def test_explicit_spec_used(self):
46
  router = ModelRouter(
47
  offline=False,
48
+ specs={
49
+ "tiny": ProfileSpec(
50
+ model="openai/nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16",
51
+ base_url="https://ws--nemotron-3-nano-4b.modal.run/v1",
52
+ api_key="EMPTY",
53
+ temperature=0.5,
54
+ max_tokens=128,
55
+ )
56
+ },
57
  )
58
  provider = router.for_profile("tiny")
59
+ assert isinstance(provider, LiteLLMProvider)
60
+ assert provider.model == "openai/nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
61
+ assert provider.api_base == "https://ws--nemotron-3-nano-4b.modal.run/v1"
62
+ assert provider.api_key == "EMPTY"
63
  assert provider.temperature == 0.5
64
  assert provider.max_tokens == 128
65