You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by gs...@apache.org on 2026/08/08 23:26:32 UTC
(tooling-llmao) branch main updated: Drop Settings; use APP.cfg EasyDict with dotted access.
This is an automated email from the ASF dual-hosted git repository.
gstein pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-llmao.git
The following commit(s) were added to refs/heads/main by this push:
new 0de2bde Drop Settings; use APP.cfg EasyDict with dotted access.
0de2bde is described below
commit 0de2bdecfcce1aad8bd1e31e45669b3b4d5fbe36
Author: Greg Stein <gs...@gmail.com>
AuthorDate: Sat Aug 8 18:26:26 2026 -0500
Drop Settings; use APP.cfg EasyDict with dotted access.
Remove llmao/config.py and LLMAO_SETTINGS. Wire main, seam, backend, auth,
pages, and API from asfquart app.cfg (and the same EasyDict in tests) so
configuration is one object with cfg.litellm.* / cfg.budgets.* style access.
Co-authored-by: Grok <gr...@x.ai>
---
README.md | 2 +-
api.py | 9 +++----
docs/ARCHITECTURE.md | 1 +
llmao/auth.py | 25 +++++++------------
llmao/config.py | 64 -------------------------------------------------
llmao/litellm_client.py | 43 +++++++++++++--------------------
llmao/seam.py | 16 +++++--------
main.py | 12 ++++------
pages.py | 3 +--
tests/test_phase1.py | 50 ++++++++++++++++++++++----------------
10 files changed, 72 insertions(+), 153 deletions(-)
diff --git a/README.md b/README.md
index ad02313..215c70e 100644
--- a/README.md
+++ b/README.md
@@ -191,6 +191,6 @@ bin/gen-litellm-master-key.sh print sk-… for both YAML files
config.yaml.example → config.yaml (gitignored)
litellm.yaml.example → litellm.yaml (gitignored)
certs/ mkcert PEMs + README
-llmao/ seam, auth, litellm_client, store, config
+llmao/ seam, auth, litellm_client, store (cfg via APP.cfg)
tests/ offline seam/catalog tests
```
diff --git a/api.py b/api.py
index 63c2a8d..c30596c 100644
--- a/api.py
+++ b/api.py
@@ -38,16 +38,14 @@ def _err(status: int, message: str) -> Response:
@APP.get("/healthz")
async def healthz():
- s = APP.config["LLMAO_SETTINGS"]
- return jsonify({"status": "ok", "llm_mode": s.litellm_mode})
+ return jsonify({"status": "ok", "llm_mode": APP.cfg.litellm.mode})
@APP.get("/v1/projects/<project>/usage")
@asfquart.auth.require
async def project_usage(project: str):
- s = APP.config["LLMAO_SETTINGS"]
seam = APP.config["LLMAO_SEAM"]
- ident = await current_identity(s)
+ ident = await current_identity(APP.cfg)
assert ident is not None
try:
rows = await seam.project_activity(ident, project)
@@ -65,9 +63,8 @@ async def project_usage(project: str):
@APP.get("/v1/projects/<project>/budget")
@asfquart.auth.require({R.committer})
async def project_budget(project: str):
- s = APP.config["LLMAO_SETTINGS"]
seam = APP.config["LLMAO_SEAM"]
- ident = await current_identity(s)
+ ident = await current_identity(APP.cfg)
assert ident is not None
try:
seam.require_member(ident, project)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 2a0b77a..3f470a5 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -26,6 +26,7 @@ both bind to `asfquart.APP` after construct. Standalone: `python main.py`
Local TLS: `config.yaml` `server.certfile` / `keyfile` under `certs/`, typically
mkcert for **`localhost.apache.org`**. `config.yaml` is gitignored (secrets).
+Loaded as **`APP.cfg`** (EasyDict); use dotted access (`APP.cfg.litellm.mode`).
## The two halves
diff --git a/llmao/auth.py b/llmao/auth.py
index 41cace0..a85a536 100644
--- a/llmao/auth.py
+++ b/llmao/auth.py
@@ -1,46 +1,39 @@
"""Authentication / identity resolution via asfquart.
-Reads the real asfquart ``ClientSession`` (oauth.apache.org + LDAP) and maps it
-to :class:`~llmao.seam.Identity`. Optional ``token_handler`` support for
-bearer tokens against *this* app remains a stub until PAT management lands.
+``cfg`` is ``APP.cfg`` (EasyDict). Site admins from ``cfg.site_admins``.
"""
from __future__ import annotations
-from typing import Optional
+from typing import Any, Optional
import asfquart.session as asf_session
-from .config import Settings
from .seam import Identity
-async def current_identity(settings: Settings) -> Optional[Identity]:
+async def current_identity(cfg: Any) -> Optional[Identity]:
"""Resolve the calling identity from the asfquart session (if any)."""
client_session = await asf_session.read()
if client_session is None or not getattr(client_session, "uid", None):
return None
- return identity_from_session(client_session, settings)
+ return identity_from_session(client_session, cfg)
-def identity_from_session(client_session, settings: Settings) -> Identity:
+def identity_from_session(client_session, cfg: Any) -> Identity:
+ site_admins = list(cfg.site_admins or [])
return Identity(
uid=client_session.uid,
projects=list(getattr(client_session, "projects", []) or []),
committees=list(getattr(client_session, "committees", []) or []),
is_site_admin=(
- client_session.uid in settings.site_admins
+ client_session.uid in site_admins
or bool(getattr(client_session, "isRoot", False))
),
)
-def make_token_handler(settings: Settings):
- """Return an asfquart token_handler that maps a bearer token to a session dict.
-
- Placeholder until llmao manages PATs for its own API. Returning None means
- "no session" for that token. Shape must match what asfquart expects:
- uid, pmcs, projects, etc.
- """
+def make_token_handler(cfg: Any):
+ """asfquart token_handler stub until app-level PATs exist."""
async def token_handler(token: str):
return None
return token_handler
diff --git a/llmao/config.py b/llmao/config.py
deleted file mode 100644
index 57ad78a..0000000
--- a/llmao/config.py
+++ /dev/null
@@ -1,64 +0,0 @@
-"""Configuration for the llmao control plane.
-
-Settings are loaded from asfquart's ``app.cfg`` (YAML ``config.yaml`` next to
-``main.py``). That file is local-only and holds secrets — see
-``config.yaml.example``. There is no separate "auth mode": the app is always
-asfquart.
-"""
-from __future__ import annotations
-
-from dataclasses import dataclass, field
-from typing import Any, List, Mapping, Optional
-
-
-def _get(mapping: Any, key: str, default: Any = None) -> Any:
- if mapping is None:
- return default
- if isinstance(mapping, Mapping):
- return mapping.get(key, default)
- return getattr(mapping, key, default)
-
-
-@dataclass
-class Settings:
- # --- litellm backend --------------------------------------------------
- # "mock" -> in-process fake teams/usage (no network; good for demos/CI).
- # "proxy" -> talk to a real litellm proxy admin API (production).
- litellm_mode: str = "mock"
- litellm_base_url: str = "http://localhost:4000"
- litellm_master_key: str = "sk-llmao-master-dev"
- request_timeout_s: int = 30
-
- # --- Budgets ----------------------------------------------------------
- default_team_budget_usd: float = 100.0
- budget_duration: str = "30d"
-
- # --- Storage / admins -------------------------------------------------
- state_path: str = "./llmao-state.json"
- site_admins: List[str] = field(default_factory=list)
-
- @classmethod
- def from_cfg(cls, cfg: Any = None) -> Settings:
- """Build settings from an asfquart EasyDict / plain dict config root."""
- cfg = cfg or {}
- litellm = _get(cfg, "litellm") or {}
- budgets = _get(cfg, "budgets") or {}
- site_admins = _get(cfg, "site_admins") or []
- if isinstance(site_admins, str):
- site_admins = [s.strip() for s in site_admins.split(",") if s.strip()]
-
- mode = str(_get(litellm, "mode", "mock") or "mock").strip().lower()
- return cls(
- litellm_mode=mode,
- litellm_base_url=str(_get(litellm, "base_url", "http://localhost:4000")),
- litellm_master_key=str(_get(litellm, "master_key", "sk-llmao-master-dev")),
- request_timeout_s=int(_get(litellm, "request_timeout_s", 30)),
- default_team_budget_usd=float(_get(budgets, "default_team_budget_usd", 100)),
- budget_duration=str(_get(budgets, "duration", "30d")),
- state_path=str(_get(cfg, "state_path", "./llmao-state.json")),
- site_admins=list(site_admins),
- )
-
- @property
- def is_mock_llm(self) -> bool:
- return self.litellm_mode != "proxy"
diff --git a/llmao/litellm_client.py b/llmao/litellm_client.py
index b7ae729..59ae8e3 100644
--- a/llmao/litellm_client.py
+++ b/llmao/litellm_client.py
@@ -8,19 +8,18 @@ Two implementations behind one interface:
* ``MockBackend`` fakes team provision and usage in-process (laptop / CI).
-Project names are LDAP/session names (asfquart), used as LiteLLM team_alias
-with no rename mapping.
+``cfg`` is the asfquart EasyDict (``APP.cfg``): use dotted access
+(``cfg.litellm.mode``, etc.). Project names are LDAP/session names.
"""
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass
-from typing import Dict, List, Optional, Protocol
+from typing import Any, Dict, List, Optional, Protocol
import httpx
-from .config import Settings
from .store import StateStore
@@ -47,13 +46,9 @@ class Backend(Protocol):
async def aclose(self) -> None: ...
-# ---------------------------------------------------------------------------
-# Mock backend — no network, used for local/dev/CI.
-# ---------------------------------------------------------------------------
-
class MockBackend:
- def __init__(self, settings: Settings, store: StateStore):
- self._s = settings
+ def __init__(self, cfg: Any, store: StateStore):
+ self._cfg = cfg
self._store = store
async def ensure_team(self, project: str, budget_usd: float, duration: str) -> TeamInfo:
@@ -89,24 +84,21 @@ class MockBackend:
return None
-# ---------------------------------------------------------------------------
-# Proxy backend — real litellm proxy over async HTTP.
-# ---------------------------------------------------------------------------
-
class ProxyBackend:
"""Talks to a running litellm proxy via httpx.AsyncClient."""
- def __init__(self, settings: Settings, store: StateStore):
- self._s = settings
+ def __init__(self, cfg: Any, store: StateStore):
+ self._cfg = cfg
self._store = store
- base = settings.litellm_base_url.rstrip("/") + "/"
+ base = cfg.litellm.base_url.rstrip("/") + "/"
+ timeout_s = int(cfg.litellm.request_timeout_s)
self._client = httpx.AsyncClient(
base_url=base,
headers={
- "Authorization": f"Bearer {settings.litellm_master_key}",
+ "Authorization": f"Bearer {cfg.litellm.master_key}",
"Content-Type": "application/json",
},
- timeout=httpx.Timeout(settings.request_timeout_s),
+ timeout=httpx.Timeout(timeout_s),
)
async def aclose(self) -> None:
@@ -118,7 +110,6 @@ class ProxyBackend:
return existing
try:
- # LDAP/session project name → LiteLLM team_alias (no rename map).
team_resp = await self._client.post(
"team/new",
json={
@@ -138,11 +129,11 @@ class ProxyBackend:
key = key_resp.json().get("key")
except httpx.TimeoutException as e:
raise BackendUnavailable(
- f"LiteLLM admin API timed out after {self._s.request_timeout_s}s"
+ f"LiteLLM admin API timed out after {self._cfg.litellm.request_timeout_s}s"
) from e
except httpx.ConnectError as e:
raise BackendUnavailable(
- f"could not reach LiteLLM at {self._s.litellm_base_url}"
+ f"could not reach LiteLLM at {self._cfg.litellm.base_url}"
) from e
except httpx.HTTPStatusError as e:
raise BackendUnavailable(
@@ -181,7 +172,7 @@ class ProxyBackend:
return [r for r in rows if r.get("project") == project]
-def make_backend(settings: Settings, store: StateStore) -> Backend:
- if settings.is_mock_llm:
- return MockBackend(settings, store)
- return ProxyBackend(settings, store)
+def make_backend(cfg: Any, store: StateStore) -> Backend:
+ if cfg.litellm.mode != "proxy":
+ return MockBackend(cfg, store)
+ return ProxyBackend(cfg, store)
diff --git a/llmao/seam.py b/llmao/seam.py
index 17d1e28..cf368cf 100644
--- a/llmao/seam.py
+++ b/llmao/seam.py
@@ -1,17 +1,13 @@
"""The seam — ASF identity joined to LiteLLM teams.
asfquart tells us *who the user is and what projects they're on* (LDAP names).
-LiteLLM holds *budgets and virtual keys*. The seam authorizes and resolves a
-project name to a LiteLLM team (provisioning with a budget on first use).
-
-Project strings are session project/committee names — no rename mapping.
+LiteLLM holds *budgets and virtual keys*. ``cfg`` is ``APP.cfg`` (EasyDict).
"""
from __future__ import annotations
from dataclasses import dataclass
-from typing import Dict, List, Optional
+from typing import Any, Dict, List, Optional
-from .config import Settings
from .litellm_client import Backend, TeamInfo
@@ -35,16 +31,16 @@ class Identity:
class Seam:
- def __init__(self, settings: Settings, backend: Backend):
- self._s = settings
+ def __init__(self, cfg: Any, backend: Backend):
+ self._cfg = cfg
self._backend = backend
async def ensure_project_team(self, project: str) -> TeamInfo:
"""Resolve (provisioning if needed) the LiteLLM team for an ASF project."""
return await self._backend.ensure_team(
project,
- budget_usd=self._s.default_team_budget_usd,
- duration=self._s.budget_duration,
+ budget_usd=float(self._cfg.budgets.default_team_budget_usd),
+ duration=self._cfg.budgets.duration,
)
async def team_status(self, project: str) -> Optional[TeamInfo]:
diff --git a/main.py b/main.py
index 339eb1e..742f4c8 100644
--- a/main.py
+++ b/main.py
@@ -50,17 +50,15 @@ def create_app():
)
from llmao.auth import make_token_handler
- from llmao.config import Settings
from llmao.litellm_client import make_backend
from llmao.seam import Seam
from llmao.store import StateStore
- s = Settings.from_cfg(app.cfg)
- store = StateStore(s.state_path)
- backend = make_backend(s, store)
- seam = Seam(s, backend)
- app.token_handler = make_token_handler(s)
- app.config["LLMAO_SETTINGS"] = s
+ # Config is app.cfg (EasyDict from config.yaml) — dotted access throughout.
+ store = StateStore(app.cfg.state_path)
+ backend = make_backend(app.cfg, store)
+ seam = Seam(app.cfg, backend)
+ app.token_handler = make_token_handler(app.cfg)
app.config["LLMAO_SEAM"] = seam
app.config["LLMAO_BACKEND"] = backend
diff --git a/pages.py b/pages.py
index f1a1ea6..6d008c3 100644
--- a/pages.py
+++ b/pages.py
@@ -39,8 +39,7 @@ async def basic_info() -> edict:
basic.title = "llmao"
basic.flashes = []
- settings = APP.config["LLMAO_SETTINGS"]
- basic.llm_mode = settings.litellm_mode
+ basic.llm_mode = APP.cfg.litellm.mode
client_session = await asfquart.session.read()
if client_session is not None and getattr(client_session, "uid", None):
diff --git a/tests/test_phase1.py b/tests/test_phase1.py
index 274c207..d8fd798 100644
--- a/tests/test_phase1.py
+++ b/tests/test_phase1.py
@@ -10,27 +10,35 @@ import tempfile
import time
import pytest
+from easydict import EasyDict as edict
-from llmao.config import Settings
from llmao.store import StateStore
from llmao.litellm_client import MockBackend
from llmao.seam import Seam, Identity, AuthzError
from llmao import catalog
-def _settings(tmp):
- return Settings(
- litellm_mode="mock",
- state_path=os.path.join(tmp, "state.json"),
- default_team_budget_usd=100.0,
- site_admins=["root"],
- )
+def _cfg(tmp):
+ return edict({
+ "litellm": {
+ "mode": "mock",
+ "base_url": "http://127.0.0.1:4000",
+ "master_key": "sk-test",
+ "request_timeout_s": 30,
+ },
+ "budgets": {
+ "default_team_budget_usd": 100.0,
+ "duration": "30d",
+ },
+ "site_admins": ["root"],
+ "state_path": os.path.join(tmp, "state.json"),
+ })
def _seam(tmp):
- s = _settings(tmp)
- store = StateStore(s.state_path)
- return Seam(s, MockBackend(s, store)), s, store
+ cfg = _cfg(tmp)
+ store = StateStore(cfg.state_path)
+ return Seam(cfg, MockBackend(cfg, store)), cfg, store
def _seed_usage(store: StateStore, project: str, cost: float = 0.01) -> None:
@@ -49,19 +57,19 @@ def _seed_usage(store: StateStore, project: str, cost: float = 0.01) -> None:
store.update(_mut)
-def test_settings_from_cfg():
- s = Settings.from_cfg({
+def test_cfg_dotted_access():
+ cfg = edict({
"litellm": {"mode": "proxy", "base_url": "http://llm:4000", "master_key": "sk-x"},
"budgets": {"default_team_budget_usd": 50, "duration": "7d"},
"site_admins": ["alice"],
"state_path": "/tmp/s.json",
})
- assert s.litellm_mode == "proxy"
- assert not s.is_mock_llm
- assert s.litellm_base_url == "http://llm:4000"
- assert s.default_team_budget_usd == 50
- assert s.budget_duration == "7d"
- assert s.site_admins == ["alice"]
+ assert cfg.litellm.mode == "proxy"
+ assert cfg.litellm.mode == "proxy" and cfg.litellm.mode != "mock"
+ assert cfg.litellm.base_url == "http://llm:4000"
+ assert cfg.budgets.default_team_budget_usd == 50
+ assert cfg.budgets.duration == "7d"
+ assert cfg.site_admins == ["alice"]
def test_catalog_has_governance_metadata():
@@ -74,11 +82,11 @@ def test_catalog_has_governance_metadata():
def test_ensure_team_provisions_budget():
async def run():
with tempfile.TemporaryDirectory() as tmp:
- seam, s, _ = _seam(tmp)
+ seam, cfg, _ = _seam(tmp)
info = await seam.ensure_project_team("airflow")
assert info.team_id
assert info.key.startswith("sk-")
- assert info.max_budget == s.default_team_budget_usd
+ assert info.max_budget == float(cfg.budgets.default_team_budget_usd)
assert info.spend == 0.0
again = await seam.ensure_project_team("airflow")
assert again.team_id == info.team_id
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org