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