agharsallah Codex commited on
Commit Β·
7df0a45
1
Parent(s): 98938c3
feat: LiteLLM gateway routing profiles to Modal-served small models
Browse filesAdd 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 +17 -2
- config/models.yaml +34 -10
- docs/adr/0015-litellm-gateway-modal-models.md +94 -0
- docs/architecture/model-routing.md +63 -13
- pyproject.toml +7 -0
- src/core/conductor.py +4 -2
- src/core/config.py +17 -0
- src/core/registry.py +29 -1
- src/models/litellm_provider.py +131 -0
- src/models/openai_compat.py +13 -3
- src/models/router.py +14 -3
- tests/test_conductor.py +40 -0
- tests/test_litellm_provider.py +164 -0
- tests/test_router.py +14 -4
.env.example
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
# Copy to .env and fill in your values.
|
| 2 |
-
#
|
|
|
|
| 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
|
|
|
|
| 5 |
# true = always the deterministic stub (reproducible demos / CI)
|
| 6 |
# false = always live inference
|
| 7 |
#
|
| 8 |
-
#
|
| 9 |
-
#
|
| 10 |
-
#
|
| 11 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
offline: null
|
| 13 |
profiles:
|
| 14 |
tiny:
|
| 15 |
-
model:
|
|
|
|
|
|
|
| 16 |
temperature: 0.7
|
| 17 |
max_tokens: 160
|
| 18 |
fast:
|
| 19 |
-
model:
|
|
|
|
|
|
|
| 20 |
temperature: 0.9
|
| 21 |
max_tokens: 220
|
| 22 |
balanced:
|
| 23 |
-
model:
|
|
|
|
|
|
|
| 24 |
temperature: 0.8
|
| 25 |
max_tokens: 320
|
| 26 |
strong:
|
| 27 |
-
model:
|
|
|
|
|
|
|
| 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:
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
| 39 |
```
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
## Offline determinism
|
| 45 |
|
| 46 |
-
With no
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
| 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` β
|
|
|
|
|
|
| 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 |
-
|
| 164 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 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
|
| 82 |
model=spec.model,
|
| 83 |
-
|
|
|
|
| 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.
|
| 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={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
)
|
| 50 |
provider = router.for_profile("tiny")
|
| 51 |
-
assert isinstance(provider,
|
| 52 |
-
assert provider.model == "
|
|
|
|
|
|
|
| 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 |
|