juniper-service-core
The shared FastAPI service-tier framework every Juniper model service is built on.
Standing up a model as a service means writing the same plumbing every time: an app factory,
settings, health probes, API-key auth, Docker-secret loading, security middleware, a training
lifecycle with status tracking and event streaming, the HTTP routes that drive it, live WebSocket
streams, and — for distributed training — a worker pool. juniper-service-core is that plumbing,
factored out of juniper-cascor into one
model-agnostic package. You bring the model; it brings the service.
The -core suffix marks it as genuinely shared: it carries no model, training, or domain logic —
those stay in the owning service and are injected. A new Juniper model service (regression,
classification, whatever the model does) wires its model into the lifecycle and gets a consistent
HTTP + WebSocket surface for free.
Dependency-free top-level import
import juniper_service_core pulls in no third-party runtime dependency — only __version__
loads eagerly. The rest of the public surface (create_app, SettingsBase, and the security /
secrets / middleware / lifecycle / routes / websocket / worker helpers) resolves lazily on attribute
access (PEP 562 __getattr__), so fastapi / pydantic-settings / starlette / numpy load only
when you touch a name that needs them. That's what lets a --no-deps publish-verify import the
package cleanly.
Install
pip install juniper-service-core
What's in the box
| Subsystem | Import surface | What it gives you |
|---|---|---|
| App factory | create_app |
Model-agnostic FastAPI app: mounts the health router, then any service-supplied routers. |
| Settings | SettingsBase |
pydantic-settings base (service_name, host, port, log_level); subclass and set your own env_prefix. |
| Health | (mounted by create_app) |
GET /v1/health (liveness) + GET /v1/health/ready (readiness). |
| Security | APIKeyAuth, RateLimiter, build_api_key_auth, … |
X-API-Key authentication + rate limiting. Keys are compared as UTF-8 bytes (surrogatepass): a non-ASCII header that does not match is a 401 (WebSocket close 4001), never a 500. See API-key comparison. |
| Secrets | get_secret |
Docker _FILE secret-indirection reader. |
| Middleware | SecurityMiddleware, SecurityHeadersMiddleware, RequestBodyLimitMiddleware |
Drop-in ASGI middleware. |
| Launcher | ManagedService, start_service, wait_for_health |
Subprocess service launcher (stdlib-only). |
| Lifecycle | TrainingLifecycle, ServiceLifecycleManager, … |
Drives a juniper-model-core TrainableModel through a status FSM + a TrainingEvent monitor; synchronous and threaded-orchestrator bodies, with snapshots + replay. |
| Generic routes | build_routers, ResponseEnvelope, … |
Training-control, metrics, dataset, network, and snapshot HTTP routes over the injected lifecycle. |
| WebSocket | attach_websocket, training_stream_handler, control_stream_handler, … |
Live training + control streams, plus a worker channel (/ws/workers). |
| Worker pool | WorkerCoordinator, WorkerRegistry, … |
Distributed-worker registration, coordination, and task dispatch (stdlib-only foundations). |
Import cost tracks the subsystem: .security, .secrets, .middleware, .launcher, and .workers
are stdlib-/lightweight; .lifecycle needs juniper-model-core; .routes and .websocket need
fastapi.
Quick start
A minimal, health-only service:
from juniper_service_core import create_app, SettingsBase
from pydantic_settings import SettingsConfigDict
class MyServiceSettings(SettingsBase):
model_config = SettingsConfigDict(env_prefix="JUNIPER_MYSVC_")
app = create_app(title="My Service", version="1.0.0")
# GET /v1/health -> {"status": "ok"}
# GET /v1/health/ready -> {"status": "ready"}
To serve a model, wrap it in a ServiceLifecycleManager, mount the generic routers, and expose
the lifecycle on app.state — the routes read it from there:
from juniper_service_core import create_app, ServiceLifecycleManager, build_routers
manager = ServiceLifecycleManager(MyModel()) # MyModel = any juniper-model-core TrainableModel
app = create_app(title="My Model Service", version="1.0.0", routers=build_routers())
app.state.lifecycle = manager
# -> POST /v1/train, GET /v1/training/status, and the metrics / dataset / network / snapshot
# routes, all driving your model. (Routes return 503 until app.state.lifecycle is wired.)
juniper-recurrence is the canonical worked
example — its FastAPI service is essentially this wiring around an LMU regressor.
Who uses it
- juniper-recurrence — the first real consumer;
its service is
create_app+ a lifecycle around the LMU regressor. - juniper-cascor — the framework was extracted from cascor's service tier; cascor's own cutover onto it is in progress.
Any new Juniper model service should build on this rather than re-implementing the plumbing.
API-key comparison
APIKeyAuth.validate compares UTF-8 bytes with surrogatepass, not str. hmac.compare_digest raises TypeError when either string holds a non-ASCII character, and Starlette decodes X-API-Key bytes as latin-1, so a byte above 0x7f used to leave SecurityMiddleware as a 500.
Only an HTTP 401 is recorded by FailedAuthThrottle (default 10 failures per source IP per 60 seconds, then 429), so that flood was never throttled. The WebSocket handshake calls the same validate and closes 4001 instead of raising.
surrogatepass is required: it encodes a lone surrogate (a JSON-decoded config value can contain one) and it does not map two different strings onto the same bytes. surrogateescape does both of those wrong.
Releases up to and including 0.7.0 compare str and still return a 500 on such a header; 0.7.1 is the first release with the bytes compare (juniper-ml#2086 made the change). Do not catch the TypeError. The Sentry half of the same incident — frame locals, including the one holding the configured key — is juniper-observability's configure_sentry. Operator notes: juniper-ml REFERENCE.
Status
Live on PyPI (Beta). The current version is shown by the badge above; see
CHANGELOG.md for history. Pin with juniper-service-core>=0.2.0,<0.8.0 —
the range the juniper-ml meta-package uses for its tools / all extras.
Compatibility policy — pinning, not deprecation cycles
This package ships no deprecation machinery: no DeprecationWarning, no
PendingDeprecationWarning, no legacy aliases. That is deliberate, not an oversight
(defect-register APD-ECO-007).
While pre-1.0, compatibility is managed by external pinning: consumers pin
>=floor,<next-minor, so each 0.x is a compatibility boundary and a breaking change
is absorbed by a ceiling raise the consumer performs deliberately. That works because
the consumer set is a known, enumerable handful of first-party repositories — and it
has been exercised: the two changes this package has shipped marked "potentially
breaking" were both cleared by censusing the consumers and verifying zero were
affected, which a deprecation cycle would only have made slower.
What would change this. A deprecation cycle is the right tool the moment the consumer set stops being enumerable — a third-party dependant, or 1.0. At that point do not invent machinery: two implementations already exist in this ecosystem and should be reused rather than re-derived, since re-derivation is precisely how the env-var lookup drifted across three services before it was consolidated.
| Need | Use |
|---|---|
| Env-var rename | juniper_config_tools.env_with_legacy_alias |
| API/argument rename | the juniper-data-client alias pattern (warnings.warn(..., DeprecationWarning, stacklevel=N) plus a dated removal window) |
A dated window is the part that matters: it turns a deprecation from a permanent tax
into a plan. See juniper-cascor-client's AUTO_PONG_REMOVAL_VERSION for a worked
example, including how to verify stacklevel attributes the warning to the caller
rather than to library code.
Development
pip install -e ".[test]"
pytest tests/ -v
Design
Part of the Juniper ML research platform. The architecture and the
cascor extraction plan are the model/middleware refactor design of record
(notes/JUNIPER_2026-05-31_JUNIPER-ECOSYSTEM_MODEL-MIDDLEWARE-REFACTOR-DESIGN-AND-PLAN.md in juniper-ml).
License
MIT — see LICENSE.
Metadata
Release files for juniper-service-core 0.7.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| juniper_service_core-0.7.1.tar.gz | 203.6 kB | With provenance | Details for juniper_service_core-0.7.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| juniper_service_core-0.7.1-py3-none-any.whl | Python 3 | none | any | Details for juniper_service_core-0.7.1-py3-none-any.whl |
Total release size: 340.6 kB
Release files / juniper_service_core-0.7.1.tar.gz
| Download URL | juniper_service_core-0.7.1.tar.gz |
|---|---|
| Size | 203.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
11cb358a122ad7abe5a31dba32fcc99e1d017bc16a51905bb414b0d0d25d65a8
|
|
BLAKE2b-256 checksum How to use checksums |
310d2d1cb3c9116e14b7faf85a3d99a8d9270a9e504fed5c83e06e1eb3decc98
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency logRelease files / juniper_service_core-0.7.1-py3-none-any.whl
| Download URL | juniper_service_core-0.7.1-py3-none-any.whl |
|---|---|
| Size | 137.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6f942c03b27e30d6602353da0bd0c3d9a9f30bc3c017503b868e3cc6b3d99fe5
|
|
BLAKE2b-256 checksum How to use checksums |
1475d141b52119f17a34b9e950ff84a17b060aea84b8d8dd2f163d1b96e16a81
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency logSecurity for juniper-service-core 0.7.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for juniper_service_core-0.7.1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.