sie-config 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. sie_config-0.8.0/.gitignore +273 -0
  2. sie_config-0.8.0/Dockerfile +82 -0
  3. sie_config-0.8.0/PKG-INFO +22 -0
  4. sie_config-0.8.0/pyproject.toml +41 -0
  5. sie_config-0.8.0/src/sie_config/__init__.py +1 -0
  6. sie_config-0.8.0/src/sie_config/app_factory.py +249 -0
  7. sie_config-0.8.0/src/sie_config/cli.py +125 -0
  8. sie_config-0.8.0/src/sie_config/config_api.py +1645 -0
  9. sie_config-0.8.0/src/sie_config/config_store.py +218 -0
  10. sie_config-0.8.0/src/sie_config/health.py +27 -0
  11. sie_config-0.8.0/src/sie_config/managed_metrics.py +431 -0
  12. sie_config-0.8.0/src/sie_config/metrics.py +72 -0
  13. sie_config-0.8.0/src/sie_config/model_registry.py +1796 -0
  14. sie_config-0.8.0/src/sie_config/nats_publisher.py +382 -0
  15. sie_config-0.8.0/src/sie_config/types.py +58 -0
  16. sie_config-0.8.0/src/sie_config/version.py +12 -0
  17. sie_config-0.8.0/tests/__init__.py +0 -0
  18. sie_config-0.8.0/tests/conftest.py +76 -0
  19. sie_config-0.8.0/tests/test_canonical_profile_conformance.py +46 -0
  20. sie_config-0.8.0/tests/test_catalog_withdrawal.py +205 -0
  21. sie_config-0.8.0/tests/test_config_api.py +2392 -0
  22. sie_config-0.8.0/tests/test_config_store.py +136 -0
  23. sie_config-0.8.0/tests/test_dockerfile_contract.py +11 -0
  24. sie_config-0.8.0/tests/test_hash_consistency.py +380 -0
  25. sie_config-0.8.0/tests/test_managed_metrics.py +461 -0
  26. sie_config-0.8.0/tests/test_metrics.py +239 -0
  27. sie_config-0.8.0/tests/test_model_registry.py +1057 -0
  28. sie_config-0.8.0/tests/test_model_registry_mutation.py +730 -0
  29. sie_config-0.8.0/tests/test_nats_publisher.py +340 -0
  30. sie_config-0.8.0/tests/test_telemetry_benchmark.py +139 -0
@@ -0,0 +1,273 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ .codex/artifacts/worktree-task/
3
+
4
+ __pycache__/
5
+ *.py[codz]
6
+ *$py.class
7
+
8
+ # C extensions
9
+ *.so
10
+
11
+ # Distribution / packaging
12
+ .Python
13
+ build/
14
+ develop-eggs/
15
+ dist/
16
+ downloads/
17
+ eggs/
18
+ .eggs/
19
+ lib/
20
+ lib64/
21
+ # JS/TS projects under tools/ legitimately use a `lib/` directory.
22
+ !tools/*/lib/
23
+ !tools/*/lib/**
24
+ parts/
25
+ sdist/
26
+ var/
27
+ wheels/
28
+ share/python-wheels/
29
+ *.egg-info/
30
+ .installed.cfg
31
+ *.egg
32
+ MANIFEST
33
+
34
+ # PyInstaller
35
+ # Usually these files are written by a python script from a template
36
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
37
+ *.manifest
38
+ *.spec
39
+
40
+ # Installer logs
41
+ pip-log.txt
42
+ pip-delete-this-directory.txt
43
+
44
+ # Unit test / coverage reports
45
+ htmlcov/
46
+ .tox/
47
+ .nox/
48
+ .coverage
49
+ .coverage.*
50
+ .cache
51
+ nosetests.xml
52
+ coverage.xml
53
+ *.cover
54
+ *.py.cover
55
+ .hypothesis/
56
+ .pytest_cache/
57
+ cover/
58
+
59
+ # Translations
60
+ *.mo
61
+ *.pot
62
+
63
+ # Django stuff:
64
+ *.log
65
+ local_settings.py
66
+ db.sqlite3
67
+ db.sqlite3-journal
68
+
69
+ # Flask stuff:
70
+ instance/
71
+ .webassets-cache
72
+
73
+ # Scrapy stuff:
74
+ .scrapy
75
+
76
+ # Sphinx documentation
77
+ docs/_build/
78
+
79
+ # PyBuilder
80
+ .pybuilder/
81
+ target/
82
+
83
+ # Rust
84
+ *.rs.bk
85
+
86
+ # Jupyter Notebook
87
+ .ipynb_checkpoints
88
+
89
+ # IPython
90
+ profile_default/
91
+ ipython_config.py
92
+
93
+ # pyenv
94
+ # For a library or package, you might want to ignore these files since the code is
95
+ # intended to run in multiple environments; otherwise, check them in:
96
+ # .python-version
97
+
98
+ # pipenv
99
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
100
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
101
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
102
+ # install all needed dependencies.
103
+ #Pipfile.lock
104
+
105
+ # UV
106
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
107
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
108
+ # commonly ignored for libraries.
109
+ #uv.lock
110
+
111
+ # poetry
112
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
113
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
114
+ # commonly ignored for libraries.
115
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
116
+ #poetry.lock
117
+ #poetry.toml
118
+
119
+ # pdm
120
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
121
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
122
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
123
+ #pdm.lock
124
+ #pdm.toml
125
+ .pdm-python
126
+ .pdm-build/
127
+
128
+ # pixi
129
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
130
+ #pixi.lock
131
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
132
+ # in the .venv directory. It is recommended not to include this directory in version control.
133
+ .pixi
134
+
135
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
136
+ __pypackages__/
137
+
138
+ # Celery stuff
139
+ celerybeat-schedule
140
+ celerybeat.pid
141
+
142
+ # SageMath parsed files
143
+ *.sage.py
144
+
145
+ # Environments
146
+ .env
147
+ # Local service credential bundles must never be committed.
148
+ *-secrets.env
149
+ .env.cloud
150
+ .venv
151
+ env/
152
+ venv/
153
+ ENV/
154
+ env.bak/
155
+ venv.bak/
156
+
157
+ # Spyder project settings
158
+ .spyderproject
159
+ .spyproject
160
+
161
+ # Rope project settings
162
+ .ropeproject
163
+
164
+ # mkdocs documentation
165
+ /site
166
+
167
+ # Pyre type checker
168
+ .pyre/
169
+
170
+ # pytype static type analyzer
171
+ .pytype/
172
+
173
+ # Cython debug symbols
174
+ cython_debug/
175
+
176
+ # PyCharm
177
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
178
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
179
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
180
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
181
+ #.idea/
182
+
183
+ # Abstra
184
+ # Abstra is an AI-powered process automation framework.
185
+ # Ignore directories containing user credentials, local state, and settings.
186
+ # Learn more at https://abstra.io/docs
187
+ .abstra/
188
+
189
+ # Visual Studio Code
190
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
191
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
192
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
193
+ # you could uncomment the following to ignore the entire vscode folder
194
+ # .vscode/
195
+
196
+ # Ruff stuff:
197
+ .ruff_cache/
198
+
199
+ # PyPI configuration file
200
+ .pypirc
201
+
202
+ # Cursor
203
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
204
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
205
+ # refer to https://docs.cursor.com/context/ignore-files
206
+ .cursorignore
207
+ .cursorindexingignore
208
+
209
+ # vcscode
210
+ .vscode
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # SIE specific
218
+ # Model weights cache
219
+ .cache/
220
+ *.safetensors
221
+ *.bin
222
+
223
+ # Secrets (never commit)
224
+ *.pem
225
+ *.key
226
+ credentials.json
227
+ *-key.json
228
+
229
+ # Terraform
230
+ # Local .terraform directories (cached providers/modules)
231
+ **/.terraform/*
232
+ .terraform.lock*
233
+ # State files (contain sensitive data)
234
+ *.tfstate
235
+ *.tfstate.*
236
+ # Crash log files
237
+ crash.log
238
+ crash.*.log
239
+ # Override files (local developer overrides)
240
+ override.tf
241
+ override.tf.json
242
+ *_override.tf
243
+ *_override.tf.json
244
+ # tfvars files may contain secrets
245
+ *.tfvars
246
+ *.tfvars.json
247
+
248
+ # Node.js
249
+ node_modules/
250
+
251
+ # OS
252
+ .DS_Store
253
+ Thumbs.db
254
+
255
+ # VIM
256
+ *.swp
257
+
258
+ # kilocode
259
+ .kilo/
260
+ .kilocode/
261
+
262
+ # Worktree metadata
263
+ .base-branch
264
+
265
+ # Temporary files
266
+ tmp/
267
+ .tmp/
268
+ .local/
269
+
270
+ b6_results.json
271
+ # Personal mise overrides (per-developer toolchain tweaks, e.g. rustup
272
+ # component naming differences across rustup versions).
273
+ mise.local.toml
@@ -0,0 +1,82 @@
1
+ # syntax=docker/dockerfile:1.6
2
+ # SIE Config Service - Config control plane for SIE clusters
3
+ # Lightweight image (~150MB) - no GPU dependencies
4
+ #
5
+ # Build:
6
+ # docker build -t sie-config:latest -f packages/sie_config/Dockerfile .
7
+ #
8
+ # Run:
9
+ # docker run -p 8080:8080 sie-config:latest
10
+
11
+ # =============================================================================
12
+ # Dependency builder: install service dependencies
13
+ # =============================================================================
14
+ FROM python:3.12-slim AS builder
15
+
16
+ WORKDIR /app
17
+
18
+ # Copy sie-sdk first (dependency of sie-config)
19
+ COPY packages/sie_sdk /tmp/sie_sdk
20
+
21
+ # Copy sie-config files
22
+ COPY packages/sie_config/pyproject.toml ./
23
+ COPY packages/sie_config/src src/
24
+
25
+ # Create venv and install everything with pip
26
+ RUN --mount=type=cache,target=/root/.cache/pip \
27
+ python -m venv .venv \
28
+ && .venv/bin/pip install --upgrade pip \
29
+ && .venv/bin/pip install \
30
+ -e /tmp/sie_sdk \
31
+ -e "."
32
+
33
+ # Clean up to reduce image size
34
+ RUN find .venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true \
35
+ && find .venv -type f -name "*.pyc" -delete 2>/dev/null || true \
36
+ && find .venv -type d -name "tests" -exec rm -rf {} + 2>/dev/null || true \
37
+ && find .venv -type d -name "test" -exec rm -rf {} + 2>/dev/null || true
38
+
39
+ # =============================================================================
40
+ # Runtime image: minimal config service
41
+ # =============================================================================
42
+ FROM python:3.12-slim AS runtime
43
+
44
+ # Create non-root user
45
+ RUN groupadd --gid 1000 sie \
46
+ && useradd --uid 1000 --gid 1000 --create-home --shell /bin/bash sie
47
+
48
+ WORKDIR /app
49
+
50
+ # Copy virtual environment from builder
51
+ COPY --from=builder --chown=sie:sie /app/.venv /app/.venv
52
+
53
+ # Copy source code (pip -e creates editable install with .pth pointing to src)
54
+ COPY --from=builder --chown=sie:sie /app/src /app/src
55
+
56
+ # Copy sie-sdk source (installed as editable in /tmp)
57
+ COPY --from=builder --chown=sie:sie /tmp/sie_sdk/src /tmp/sie_sdk/src
58
+
59
+ # Copy model and bundle configs (required for ModelRegistry)
60
+ # Config service needs these to know which models exist and their bundle mappings
61
+ COPY --chown=sie:sie packages/sie_server/bundles /app/bundles
62
+ COPY --chown=sie:sie packages/sie_server/models /app/models
63
+
64
+ # Set up PATH and model config paths
65
+ ENV PATH="/app/.venv/bin:$PATH" \
66
+ PYTHONUNBUFFERED=1 \
67
+ PYTHONDONTWRITEBYTECODE=1 \
68
+ SIE_BUNDLES_DIR=/app/bundles \
69
+ SIE_MODELS_DIR=/app/models
70
+
71
+ USER sie
72
+
73
+ # Default port
74
+ EXPOSE 8080
75
+
76
+ # Health check
77
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
78
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/healthz')" || exit 1
79
+
80
+ # Default command
81
+ ENTRYPOINT ["sie-config", "serve"]
82
+ CMD ["--port", "8080"]
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.5
2
+ Name: sie-config
3
+ Version: 0.8.0
4
+ Summary: SIE Config Service - Config control plane for SIE clusters
5
+ License: Apache-2.0
6
+ Requires-Python: <3.13,>=3.12
7
+ Requires-Dist: fastapi<1,>=0.115
8
+ Requires-Dist: nats-py<3,>=2.9
9
+ Requires-Dist: opentelemetry-api<2,>=1.28
10
+ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc<2,>=1.28
11
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.28
12
+ Requires-Dist: opentelemetry-sdk<2,>=1.28
13
+ Requires-Dist: orjson<4,>=3.10
14
+ Requires-Dist: pyyaml<7,>=6
15
+ Requires-Dist: requests<3,>=2.32
16
+ Requires-Dist: rich<14,>=13
17
+ Requires-Dist: sie-sdk[storage]>=0.1
18
+ Requires-Dist: typer<1,>=0.14
19
+ Requires-Dist: uvicorn[standard]<1,>=0.32
20
+ Requires-Dist: uvloop>=0.22
21
+ Provides-Extra: gcs
22
+ Requires-Dist: google-cloud-storage<3,>=2.18; extra == 'gcs'
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "sie-config"
3
+ version = "0.8.0"
4
+ description = "SIE Config Service - Config control plane for SIE clusters"
5
+ requires-python = ">=3.12,<3.13"
6
+ license = { text = "Apache-2.0" }
7
+ dependencies = [
8
+ "fastapi>=0.115,<1",
9
+ "uvicorn[standard]>=0.32,<1",
10
+ "uvloop>=0.22",
11
+ "nats-py>=2.9,<3",
12
+ "opentelemetry-api>=1.28,<2",
13
+ "opentelemetry-exporter-otlp-proto-grpc>=1.28,<2",
14
+ "opentelemetry-exporter-otlp-proto-http>=1.28,<2",
15
+ "opentelemetry-sdk>=1.28,<2",
16
+ "orjson>=3.10,<4",
17
+ "pyyaml>=6,<7",
18
+ "requests>=2.32,<3",
19
+ "sie-sdk[storage]>=0.1",
20
+ "typer>=0.14,<1",
21
+ "rich>=13,<14",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ gcs = ["google-cloud-storage>=2.18,<3"]
26
+
27
+ [project.scripts]
28
+ sie-config = "sie_config.cli:app"
29
+
30
+ [build-system]
31
+ requires = ["hatchling"]
32
+ build-backend = "hatchling.build"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/sie_config"]
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "pytest>=9.0.3",
40
+ "pytest-asyncio>=1.0",
41
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,249 @@
1
+ import logging
2
+ import os
3
+ import time
4
+ from collections.abc import AsyncGenerator, Callable
5
+ from contextlib import asynccontextmanager
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from fastapi import FastAPI, Request
10
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
11
+ from starlette.responses import Response
12
+
13
+ from sie_config import metrics as sie_metrics
14
+ from sie_config.config_api import router as config_router
15
+ from sie_config.config_store import ConfigStore
16
+ from sie_config.health import router as health_router
17
+ from sie_config.managed_metrics import setup_managed_metrics
18
+ from sie_config.model_registry import ModelRegistry
19
+ from sie_config.nats_publisher import NatsPublisher
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Default paths for bundle and model configs.
24
+ # In Docker, SIE_BUNDLES_DIR and SIE_MODELS_DIR are always set (see Dockerfile).
25
+ # In development, fall back to the sibling sie_server package in the source tree.
26
+ _DEFAULT_BUNDLES_DIR = Path(__file__).parent.parent.parent.parent / "sie_server" / "bundles"
27
+ _DEFAULT_MODELS_DIR = Path(__file__).parent.parent.parent.parent / "sie_server" / "models"
28
+ _BOUNDED_HTTP_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"})
29
+
30
+
31
+ class _TelemetryHTTPMiddleware(BaseHTTPMiddleware):
32
+ # Record one backend-neutral config HTTP observation for every request.
33
+ #
34
+ # The `path` label is the FastAPI route template (e.g.
35
+ # `/v1/configs/models/{model_id}`) rather than the raw URL so
36
+ # per-model reads do not explode the label cardinality. We resolve
37
+ # the route after the downstream handler runs -- Starlette only
38
+ # populates `request.scope["route"]` once routing has matched. Unknown
39
+ # URLs collapse to the contract's bounded `other` label; the Modal URL is
40
+ # externally reachable, so retaining arbitrary request paths would let a
41
+ # caller create unbounded time series.
42
+ #
43
+ # The exception path matters as much as the success one. If
44
+ # `call_next(...)` raises, Starlette converts the exception into a
45
+ # 500 *outside* this middleware, so a naive `response = await
46
+ # call_next(request)` followed by `.labels(...)` would miss exactly
47
+ # the failures operators care about most. We wrap the entire
48
+ # critical section in `try / except / finally` so that:
49
+ # - a raised exception still bumps `status="500"` in the counter
50
+ # and observes latency (the timing is meaningful -- a 50 ms
51
+ # crash is very different from a 30 s one),
52
+ # - the original exception is re-raised so Starlette's default
53
+ # 500 handler runs and the client sees the same response it
54
+ # would without this middleware.
55
+
56
+ async def dispatch(
57
+ self,
58
+ request: Request,
59
+ call_next: RequestResponseEndpoint,
60
+ ) -> Response:
61
+ # `status_code` is seeded at 500 so an exception raised
62
+ # inside `call_next` (before we can read `response.status_code`)
63
+ # still attributes the failure correctly in the `finally` block.
64
+ # FastAPI/Starlette's outer exception handler turns the raised
65
+ # exception into a 500 for the client, so counting it as a 500
66
+ # matches what the caller actually sees.
67
+ start = time.monotonic()
68
+ status_code = 500
69
+ try:
70
+ response = await call_next(request)
71
+ status_code = response.status_code
72
+ return response
73
+ finally:
74
+ elapsed = time.monotonic() - start
75
+ route = request.scope.get("route")
76
+ path_label = getattr(route, "path", None) or "other"
77
+ method_label = request.method if request.method in _BOUNDED_HTTP_METHODS else "other"
78
+
79
+ sie_metrics.record_http_request(
80
+ method=method_label,
81
+ path=path_label,
82
+ status=status_code,
83
+ duration_s=elapsed,
84
+ )
85
+ if path_label == "/v1/configs/export":
86
+ sie_metrics.record_snapshot_publish(success=200 <= status_code < 300)
87
+
88
+
89
+ class AppFactory:
90
+ """Factory for creating the SIE Config Service FastAPI application."""
91
+
92
+ @classmethod
93
+ def create_app(cls) -> FastAPI:
94
+ """Create and configure the FastAPI application.
95
+
96
+ Returns:
97
+ Configured FastAPI application instance.
98
+ """
99
+ setup_managed_metrics()
100
+ app = FastAPI(
101
+ title="SIE Config Service",
102
+ description="Config control plane for SIE clusters",
103
+ version="0.1.0",
104
+ lifespan=cls._create_lifespan(),
105
+ )
106
+
107
+ # Do not install request instrumentation at all when telemetry is
108
+ # disabled. A no-op OTel meter still leaves clock reads, route
109
+ # resolution and attribute preparation on every request; omitting the
110
+ # middleware makes the public disabled path a true pass-through.
111
+ #
112
+ # When enabled, telemetry must wrap the app BEFORE the routers mount
113
+ # so it observes every request (including errors raised before a route
114
+ # matches, e.g. body-size rejections). Starlette applies middleware in
115
+ # outer-to-inner order, so adding it first makes it the outermost layer.
116
+ if sie_metrics.telemetry_enabled():
117
+ app.add_middleware(_TelemetryHTTPMiddleware)
118
+
119
+ app.include_router(health_router)
120
+ app.include_router(config_router)
121
+
122
+ return app
123
+
124
+ @classmethod
125
+ def _create_lifespan(cls) -> Callable[[FastAPI], Any]:
126
+ """Create the lifespan context manager for the application."""
127
+
128
+ @asynccontextmanager
129
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
130
+ """Application lifespan manager."""
131
+ logger.info("Starting SIE Config Service")
132
+
133
+ async with (
134
+ cls._model_registry(app),
135
+ cls._config_store(app),
136
+ cls._nats_publisher(app),
137
+ ):
138
+ yield
139
+
140
+ logger.info("Stopped SIE Config Service")
141
+
142
+ return lifespan
143
+
144
+ @classmethod
145
+ @asynccontextmanager
146
+ async def _model_registry(cls, app: FastAPI) -> AsyncGenerator[None, None]:
147
+ """Initialize ModelRegistry for model->bundle mapping."""
148
+ bundles_dir = Path(os.environ.get("SIE_BUNDLES_DIR", str(_DEFAULT_BUNDLES_DIR)))
149
+ models_dir = Path(os.environ.get("SIE_MODELS_DIR", str(_DEFAULT_MODELS_DIR)))
150
+
151
+ try:
152
+ model_registry = ModelRegistry(bundles_dir, models_dir)
153
+ app.state.model_registry = model_registry
154
+ unrouteable = model_registry.unrouteable_models
155
+ logger.info(
156
+ "ModelRegistry initialized: %d bundles, %d models (%d unrouteable)",
157
+ len(model_registry.list_bundles()),
158
+ len(model_registry.list_models()),
159
+ len(unrouteable),
160
+ )
161
+ # Seed the models gauge. At this point every model came
162
+ # from disk; the `api`-sourced tally catches up once
163
+ # ConfigStore restore runs in `_config_store`.
164
+ sie_metrics.update_models_gauge(
165
+ api_count=0,
166
+ filesystem_count=len(model_registry.list_models()),
167
+ )
168
+ except Exception:
169
+ logger.exception("Failed to initialize ModelRegistry, continuing without it")
170
+ app.state.model_registry = None
171
+
172
+ yield
173
+
174
+ @classmethod
175
+ @asynccontextmanager
176
+ async def _config_store(cls, app: FastAPI) -> AsyncGenerator[None, None]:
177
+ """Initialize config store for persisting API-added model configs."""
178
+ config_dir = os.environ.get("SIE_CONFIG_STORE_DIR")
179
+ if config_dir:
180
+ store = ConfigStore(config_dir)
181
+ app.state.config_store = store
182
+ initial_epoch = store.read_epoch()
183
+ logger.info("Config store initialized at %s (epoch=%d)", config_dir, initial_epoch)
184
+ # Mirror the persisted epoch into telemetry so the
185
+ # `sie.config.epoch` gauge reflects reality immediately on
186
+ # startup. Without this, dashboards would read 0 until the
187
+ # first successful `POST /v1/configs/models` call bumped
188
+ # the counter — which, crucially, never happens in a
189
+ # read-only control plane.
190
+ sie_metrics.set_epoch(initial_epoch)
191
+
192
+ if os.environ.get("SIE_CONFIG_RESTORE", "").lower() == "true":
193
+ model_registry: ModelRegistry | None = app.state.model_registry
194
+ if model_registry is None:
195
+ logger.warning("Cannot restore configs -- ModelRegistry not initialized")
196
+ else:
197
+ stored_models = store.load_all_models()
198
+ for model_id, model_config in stored_models.items():
199
+ try:
200
+ model_registry.add_model_config(model_config)
201
+ logger.info("Restored model from config store: %s", model_id)
202
+ except Exception:
203
+ logger.exception("Failed to restore model: %s", model_id)
204
+ if stored_models:
205
+ logger.info("Restored %d models from config store", len(stored_models))
206
+ # Recompute the split now that API-added models
207
+ # have been folded back into the registry.
208
+ api_count = len(store.list_models())
209
+ total = len(model_registry.list_models())
210
+ sie_metrics.update_models_gauge(
211
+ api_count=api_count,
212
+ filesystem_count=max(total - api_count, 0),
213
+ )
214
+ else:
215
+ app.state.config_store = None
216
+ # Epoch zero is authoritative in no-store deployments. Seed the
217
+ # synchronous gauge explicitly so dashboards distinguish that
218
+ # state from a producer that never emitted telemetry.
219
+ sie_metrics.set_epoch(0)
220
+
221
+ yield
222
+
223
+ @classmethod
224
+ @asynccontextmanager
225
+ async def _nats_publisher(cls, app: FastAPI) -> AsyncGenerator[None, None]:
226
+ """Initialize NATS publisher for config distribution."""
227
+ nats_url = os.environ.get("SIE_NATS_URL")
228
+ nats_publisher: NatsPublisher | None = None
229
+
230
+ # The asynchronous dial may take the full startup retry budget, and a
231
+ # deployment without NATS never dials at all. In both cases false is an
232
+ # authoritative current value rather than an absent series.
233
+ sie_metrics.set_nats_connected(False)
234
+
235
+ if nats_url:
236
+ nats_publisher = NatsPublisher(nats_url=nats_url)
237
+ app.state.nats_publisher = nats_publisher
238
+ # Do not await NATS here: it can outlast startup probe budgets while the
239
+ # NATS pod schedules; /healthz must bind as soon as model registry + store init finish.
240
+ nats_publisher.kickoff_connect()
241
+ else:
242
+ app.state.nats_publisher = None
243
+ logger.info("NATS not configured (SIE_NATS_URL not set) -- config distribution disabled")
244
+
245
+ try:
246
+ yield
247
+ finally:
248
+ if nats_publisher:
249
+ await nats_publisher.disconnect()