precog-api 1.0.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.
@@ -0,0 +1,25 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ .uv/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+
11
+ # Environment
12
+ .env
13
+ .env.*
14
+
15
+ # Helm packaging
16
+ dist/
17
+
18
+ # Node / TypeScript
19
+ node_modules/
20
+
21
+ # Model weights and caches — never commit these (see PREC-9 / THIRD_PARTY_NOTICES)
22
+ models/
23
+ *.safetensors
24
+ *.gguf
25
+ .cache/huggingface/
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.5
2
+ Name: precog-api
3
+ Version: 1.0.0
4
+ Summary: Precog REST API exposing TimesFM-3 forecasts.
5
+ Project-URL: Homepage, https://github.com/Albe83/precog
6
+ Project-URL: Repository, https://github.com/Albe83/precog
7
+ Project-URL: Issues, https://github.com/Albe83/precog/issues
8
+ Project-URL: Changelog, https://github.com/Albe83/precog/blob/main/CHANGELOG.md
9
+ License: MIT
10
+ Keywords: forecasting,time-series,timesfm,zero-shot
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: fastapi>=0.115
17
+ Requires-Dist: numpy>=1.26
18
+ Requires-Dist: precog-schemas<0.2.0,>=0.1.0
19
+ Requires-Dist: prometheus-client>=0.20
20
+ Requires-Dist: pydantic-settings>=2.4
21
+ Requires-Dist: pydantic>=2.7
22
+ Requires-Dist: uvicorn[standard]>=0.53.0
23
+ Provides-Extra: engine
24
+ Requires-Dist: timesfm[torch]>=3.0.2; extra == 'engine'
25
+ Provides-Extra: otel
26
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27; extra == 'otel'
27
+ Requires-Dist: opentelemetry-instrumentation-fastapi>=0.48b0; extra == 'otel'
28
+ Requires-Dist: opentelemetry-sdk>=1.27; extra == 'otel'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # precog-api
32
+
33
+ Precog **REST Execution API**: Google
34
+ [TimesFM-3](https://research.google/blog/timesfm-3-a-zero-shot-foundation-model-for-multivariate-forecasting/)
35
+ zero-shot forecasting as a synchronous, typed HTTP service.
36
+
37
+ This is the Python distribution of the Precog API. Containers and Helm remain
38
+ the preferred production deployment path; install this package for development,
39
+ labs and Python-native environments. The distribution contains **no model
40
+ weights**.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install precog-api # base install: fake engine only, no torch
46
+ pip install "precog-api[engine]" # real TimesFM-3 engine (weights download at runtime)
47
+ ```
48
+
49
+ The base install is lightweight and runs the deterministic fake engine, which is
50
+ enough to exercise the REST contract.
51
+
52
+ ## Run
53
+
54
+ ```bash
55
+ PRECOG_ENGINE=fake precog-api # http://localhost:8000
56
+ precog-api # real engine (requires the [engine] extra)
57
+ ```
58
+
59
+ Configuration uses the `PRECOG_` prefix (`PRECOG_ENGINE`, `PRECOG_DEVICE`,
60
+ `PRECOG_MAX_*`, `PRECOG_API_KEY`, ...). See the repository documentation.
61
+
62
+ ## License
63
+
64
+ Application code is MIT. The TimesFM-3 model weights are distributed under the
65
+ TimesFM Non-Commercial License v1.0 and are **not** part of this distribution.
@@ -0,0 +1,35 @@
1
+ # precog-api
2
+
3
+ Precog **REST Execution API**: Google
4
+ [TimesFM-3](https://research.google/blog/timesfm-3-a-zero-shot-foundation-model-for-multivariate-forecasting/)
5
+ zero-shot forecasting as a synchronous, typed HTTP service.
6
+
7
+ This is the Python distribution of the Precog API. Containers and Helm remain
8
+ the preferred production deployment path; install this package for development,
9
+ labs and Python-native environments. The distribution contains **no model
10
+ weights**.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install precog-api # base install: fake engine only, no torch
16
+ pip install "precog-api[engine]" # real TimesFM-3 engine (weights download at runtime)
17
+ ```
18
+
19
+ The base install is lightweight and runs the deterministic fake engine, which is
20
+ enough to exercise the REST contract.
21
+
22
+ ## Run
23
+
24
+ ```bash
25
+ PRECOG_ENGINE=fake precog-api # http://localhost:8000
26
+ precog-api # real engine (requires the [engine] extra)
27
+ ```
28
+
29
+ Configuration uses the `PRECOG_` prefix (`PRECOG_ENGINE`, `PRECOG_DEVICE`,
30
+ `PRECOG_MAX_*`, `PRECOG_API_KEY`, ...). See the repository documentation.
31
+
32
+ ## License
33
+
34
+ Application code is MIT. The TimesFM-3 model weights are distributed under the
35
+ TimesFM Non-Commercial License v1.0 and are **not** part of this distribution.
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "precog-api"
3
+ version = "1.0.0"
4
+ description = "Precog REST API exposing TimesFM-3 forecasts."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+ keywords = ["forecasting", "time-series", "timesfm", "zero-shot"]
9
+ classifiers = [
10
+ "Development Status :: 4 - Beta",
11
+ "Programming Language :: Python :: 3",
12
+ "Programming Language :: Python :: 3.12",
13
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
14
+ ]
15
+ dependencies = [
16
+ "fastapi>=0.115",
17
+ "uvicorn[standard]>=0.53.0",
18
+ "pydantic>=2.7",
19
+ "pydantic-settings>=2.4",
20
+ "prometheus-client>=0.20",
21
+ "numpy>=1.26",
22
+ "precog-schemas>=0.1.0,<0.2.0",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ engine = ["timesfm[torch]>=3.0.2"]
27
+ otel = [
28
+ "opentelemetry-sdk>=1.27",
29
+ "opentelemetry-exporter-otlp-proto-http>=1.27",
30
+ "opentelemetry-instrumentation-fastapi>=0.48b0",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/Albe83/precog"
35
+ Repository = "https://github.com/Albe83/precog"
36
+ Issues = "https://github.com/Albe83/precog/issues"
37
+ Changelog = "https://github.com/Albe83/precog/blob/main/CHANGELOG.md"
38
+
39
+ [project.scripts]
40
+ precog-api = "precog_api.__main__:main"
41
+ precog-download-model = "precog_api.warmup:main"
42
+
43
+ [build-system]
44
+ requires = ["hatchling"]
45
+ build-backend = "hatchling.build"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/precog_api"]
@@ -0,0 +1,5 @@
1
+ """Precog REST API."""
2
+
3
+ from precog_api.app import create_app
4
+
5
+ __all__ = ["create_app"]
@@ -0,0 +1,17 @@
1
+ """Package entry point: ``python -m precog_api`` / ``precog-api``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uvicorn
6
+
7
+ from precog_api.app import create_app
8
+ from precog_api.config import Settings
9
+
10
+
11
+ def main() -> None:
12
+ settings = Settings()
13
+ uvicorn.run(create_app(settings), host="0.0.0.0", port=8000)
14
+
15
+
16
+ if __name__ == "__main__":
17
+ main()
@@ -0,0 +1,427 @@
1
+ """FastAPI application factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ import uuid
9
+ from collections.abc import AsyncIterator
10
+ from contextlib import asynccontextmanager
11
+ from typing import Annotated, Any
12
+
13
+ from fastapi import Body, Depends, FastAPI, Header, HTTPException, Request
14
+ from fastapi.exceptions import RequestValidationError
15
+ from fastapi.responses import JSONResponse, Response
16
+ from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
17
+ from pydantic import BaseModel
18
+
19
+ from precog_api.config import Settings
20
+ from precog_api.engine import Engine, FakeEngine
21
+ from precog_api.mapping import to_execution_problem, to_forecast_response
22
+ from precog_api.observability import (
23
+ FORECAST_SERIES,
24
+ INFLIGHT,
25
+ METRICS_EXCLUDED_PATHS,
26
+ MODEL_LOAD_SECONDS,
27
+ REQUEST_COUNT,
28
+ REQUEST_LATENCY,
29
+ configure_logging,
30
+ request_id_var,
31
+ )
32
+ from precog_api.tracing import setup_tracing
33
+ from precog_schemas import (
34
+ Capabilities,
35
+ ExecutionFeatures,
36
+ ExecutionLimits,
37
+ ForecastRequest,
38
+ ForecastResponse,
39
+ ModelProvenance,
40
+ )
41
+
42
+ logger = logging.getLogger("precog.api")
43
+
44
+ PROBLEM_MEDIA_TYPE = "application/problem+json"
45
+
46
+ FORECAST_EXAMPLES: dict[str, Any] = {
47
+ "targets": {
48
+ "summary": "Single target with quantiles",
49
+ "value": {
50
+ "horizon": 4,
51
+ "targets": [{"id": "sales", "values": [100, 102, 101, 105, 107, 106, 108, 109]}],
52
+ "quantiles": [0.1, 0.5, 0.9],
53
+ },
54
+ },
55
+ "covariates": {
56
+ "summary": "Target with past-only and known-future covariates",
57
+ "value": {
58
+ "horizon": 3,
59
+ "targets": [{"id": "kiosk", "values": [50, 52, 51, 53, 55, 54, 56, 57]}],
60
+ "past_covariates": [
61
+ {"id": "footfall", "values": [0.1, 0.2, 0.15, 0.3, 0.4, 0.35, 0.5, 0.6]}
62
+ ],
63
+ "known_future_covariates": [
64
+ {
65
+ "id": "promo",
66
+ "history": [0, 1, 0, 0, 0, 1, 0, 0],
67
+ "future": [1, 0, 0],
68
+ }
69
+ ],
70
+ "quantiles": [0.1, 0.9],
71
+ },
72
+ },
73
+ "joint_targets": {
74
+ "summary": "Multiple targets forecast jointly",
75
+ "value": {
76
+ "horizon": 3,
77
+ "targets": [
78
+ {"id": "a", "values": [10, 11, 12, 13, 14]},
79
+ {"id": "b", "values": [20, 21, 22, 23, 24]},
80
+ ],
81
+ "quantiles": [0.5],
82
+ },
83
+ },
84
+ "point_only": {
85
+ "summary": "Point-only forecast (no quantiles)",
86
+ "value": {
87
+ "horizon": 3,
88
+ "targets": [{"id": "cpu", "values": [1.0, 2.0, 3.0, 4.0, 5.0]}],
89
+ "quantiles": [],
90
+ },
91
+ },
92
+ }
93
+
94
+
95
+ class ProblemDetail(BaseModel):
96
+ """RFC 7807 problem payload."""
97
+
98
+ type: str = "about:blank"
99
+ title: str
100
+ status: int
101
+ detail: str | None = None
102
+
103
+
104
+ def load_engine(settings: Settings) -> Engine:
105
+ """Build the configured engine."""
106
+ if settings.engine == "fake":
107
+ logger.info("using FakeEngine")
108
+ return FakeEngine()
109
+ from precog_api.engine_timesfm3 import TimesFM3Engine
110
+
111
+ logger.info("loading TimesFM-3 engine (device=%s)", settings.device)
112
+ return TimesFM3Engine(settings)
113
+
114
+
115
+ def create_app(settings: Settings | None = None, engine: Engine | None = None) -> FastAPI:
116
+ """Create the ASGI application."""
117
+ settings = settings or Settings()
118
+ configure_logging(json_logs=settings.log_json)
119
+ if settings.enable_docs:
120
+ docs_url: str | None = "/docs"
121
+ redoc_url: str | None = "/redoc"
122
+ openapi_url: str | None = "/openapi.json"
123
+ else:
124
+ docs_url = redoc_url = openapi_url = None
125
+
126
+ @asynccontextmanager
127
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
128
+ app.state.settings = settings
129
+ app.state.semaphore = asyncio.Semaphore(settings.max_concurrency)
130
+ app.state.rate_buckets = {}
131
+ app.state.engine = engine
132
+ if app.state.engine is None:
133
+ started = time.perf_counter()
134
+ app.state.engine = load_engine(settings)
135
+ MODEL_LOAD_SECONDS.set(time.perf_counter() - started)
136
+ logger.info(
137
+ "precog-api %s ready (engine=%s, model=%s)",
138
+ app.version,
139
+ settings.engine,
140
+ settings.model_name,
141
+ )
142
+ app.state.ready = app.state.engine.ready
143
+ yield
144
+
145
+ app = FastAPI(
146
+ title="Precog API",
147
+ version="0.1.0",
148
+ summary="Zero-shot forecasting with TimesFM-3.",
149
+ description=(
150
+ "Synchronous TimesFM-3 execution. "
151
+ f"Limits: horizon <= {settings.max_horizon}, context <= {settings.max_context}, "
152
+ f"targets <= {settings.max_series}. Errors use RFC 7807 "
153
+ "(`application/problem+json`)."
154
+ + (" Bearer authentication is required." if settings.api_key else "")
155
+ ),
156
+ lifespan=lifespan,
157
+ docs_url=docs_url,
158
+ redoc_url=redoc_url,
159
+ openapi_url=openapi_url,
160
+ )
161
+
162
+ if settings.otel_enabled:
163
+ setup_tracing(settings.otel_service_name, fastapi_app=app)
164
+
165
+ @app.middleware("http")
166
+ async def _observe(request: Request, call_next: Any) -> Response:
167
+ request_id = request.headers.get("x-request-id") or uuid.uuid4().hex
168
+ token = request_id_var.set(request_id)
169
+ path = request.url.path
170
+ counted = path not in METRICS_EXCLUDED_PATHS
171
+ if counted:
172
+ INFLIGHT.inc()
173
+ started = time.perf_counter()
174
+ try:
175
+ if settings.rate_limit_requests > 0 and path == "/v1/forecast":
176
+ retry_after = _register_hit(app, settings, request)
177
+ if retry_after > 0:
178
+ problem = ProblemDetail(
179
+ title="Too Many Requests",
180
+ status=429,
181
+ detail=f"rate limit exceeded; retry in {retry_after}s",
182
+ )
183
+ response: Response = JSONResponse(
184
+ status_code=429,
185
+ content=problem.model_dump(),
186
+ media_type=PROBLEM_MEDIA_TYPE,
187
+ headers={"Retry-After": str(retry_after)},
188
+ )
189
+ else:
190
+ response = await call_next(request)
191
+ else:
192
+ response = await call_next(request)
193
+ finally:
194
+ duration = time.perf_counter() - started
195
+ if counted:
196
+ INFLIGHT.dec()
197
+ request_id_var.reset(token)
198
+ response.headers["x-request-id"] = request_id
199
+ if counted:
200
+ REQUEST_COUNT.labels(request.method, path, str(response.status_code)).inc()
201
+ REQUEST_LATENCY.labels(request.method, path).observe(duration)
202
+ logger.info(
203
+ "request",
204
+ extra={
205
+ "request_id": request_id,
206
+ "method": request.method,
207
+ "path": path,
208
+ "status": response.status_code,
209
+ "duration_ms": round(duration * 1000, 2),
210
+ "client": _client_key(request),
211
+ },
212
+ )
213
+ return response
214
+
215
+ @app.exception_handler(HTTPException)
216
+ async def _http_exception_handler(_: Request, exc: HTTPException) -> JSONResponse:
217
+ problem = ProblemDetail(
218
+ title=_status_title(exc.status_code), status=exc.status_code, detail=str(exc.detail)
219
+ )
220
+ return JSONResponse(
221
+ status_code=exc.status_code,
222
+ content=problem.model_dump(),
223
+ media_type=PROBLEM_MEDIA_TYPE,
224
+ )
225
+
226
+ @app.exception_handler(RequestValidationError)
227
+ async def _validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
228
+ problem = ProblemDetail(
229
+ title="Unprocessable Entity", status=422, detail=_format_errors(exc)
230
+ )
231
+ return JSONResponse(
232
+ status_code=422, content=problem.model_dump(), media_type=PROBLEM_MEDIA_TYPE
233
+ )
234
+
235
+ def require_api_key(authorization: str | None = Header(default=None)) -> None:
236
+ if not settings.api_key:
237
+ return
238
+ expected = f"Bearer {settings.api_key}"
239
+ if authorization != expected:
240
+ raise HTTPException(status_code=401, detail="invalid or missing API key")
241
+
242
+ @app.get("/healthz", tags=["ops"])
243
+ async def healthz() -> dict[str, str]:
244
+ return {"status": "ok"}
245
+
246
+ @app.get("/readyz", tags=["ops"])
247
+ async def readyz() -> JSONResponse:
248
+ ready = bool(getattr(app.state, "ready", False))
249
+ payload = {"status": "ready" if ready else "loading"}
250
+ return JSONResponse(status_code=200 if ready else 503, content=payload)
251
+
252
+ @app.get("/metrics", include_in_schema=False)
253
+ async def metrics() -> Response:
254
+ return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
255
+
256
+ @app.get("/v1/capabilities", response_model=Capabilities, tags=["forecast"])
257
+ async def capabilities() -> Capabilities:
258
+ engine = app.state.engine
259
+ return Capabilities(
260
+ engine=settings.engine,
261
+ model=ModelProvenance(id=settings.model_id, revision=settings.model_revision),
262
+ device=settings.device,
263
+ limits=ExecutionLimits(
264
+ max_horizon=settings.max_horizon,
265
+ max_context=_min_limit(settings.max_context, engine.max_context),
266
+ max_variates=engine.max_variates,
267
+ max_targets=settings.max_series,
268
+ ),
269
+ quantile_levels=list(engine.quantile_levels),
270
+ features=ExecutionFeatures(
271
+ point_forecast=True,
272
+ probabilistic_forecast=True,
273
+ past_covariates=True,
274
+ known_future_covariates=True,
275
+ joint_targets=True,
276
+ ),
277
+ auth_required=bool(settings.api_key),
278
+ )
279
+
280
+ @app.post(
281
+ "/v1/forecast",
282
+ response_model=ForecastResponse,
283
+ dependencies=[Depends(require_api_key)],
284
+ tags=["forecast"],
285
+ summary="Forecast time series",
286
+ response_description="Point forecast and the caller-selected quantiles per target.",
287
+ responses={
288
+ 401: {"description": "Missing or invalid API key"},
289
+ 422: {"description": "Validation error or configured limit exceeded"},
290
+ 429: {"description": "Rate limit exceeded"},
291
+ 504: {"description": "Forecast timed out"},
292
+ },
293
+ )
294
+ async def forecast(
295
+ payload: Annotated[ForecastRequest, Body(openapi_examples=FORECAST_EXAMPLES)],
296
+ ) -> ForecastResponse:
297
+ _enforce_limits(payload, settings, app.state.engine)
298
+ problem = to_execution_problem(payload)
299
+ started = time.perf_counter()
300
+ async with app.state.semaphore:
301
+ try:
302
+ result = await asyncio.wait_for(
303
+ asyncio.to_thread(app.state.engine.predict, problem),
304
+ timeout=settings.request_timeout_s,
305
+ )
306
+ except TimeoutError as exc:
307
+ raise HTTPException(status_code=504, detail="forecast timed out") from exc
308
+ latency_ms = (time.perf_counter() - started) * 1000
309
+ FORECAST_SERIES.inc(len(payload.targets))
310
+ return to_forecast_response(
311
+ payload,
312
+ result,
313
+ model=settings.model_id,
314
+ revision=settings.model_revision,
315
+ latency_ms=round(latency_ms, 3),
316
+ )
317
+
318
+ return app
319
+
320
+
321
+ def _client_key(request: Request) -> str:
322
+ authorization = request.headers.get("authorization")
323
+ if authorization:
324
+ return authorization
325
+ return request.client.host if request.client else "unknown"
326
+
327
+
328
+ def _register_hit(app: FastAPI, settings: Settings, request: Request) -> int:
329
+ """Record a request and return seconds to wait (0 if allowed)."""
330
+ key = _client_key(request)
331
+ now = time.monotonic()
332
+ window = settings.rate_limit_window_s
333
+ bucket: list[float] = app.state.rate_buckets.setdefault(key, [])
334
+ while bucket and now - bucket[0] > window:
335
+ bucket.pop(0)
336
+ if len(bucket) >= settings.rate_limit_requests:
337
+ return max(1, int(window - (now - bucket[0])) + 1)
338
+ bucket.append(now)
339
+ return 0
340
+
341
+
342
+ def _min_limit(configured: int, engine_limit: int | None) -> int:
343
+ """Intersect a configured limit with the active engine's effective limit."""
344
+ return configured if engine_limit is None else min(configured, engine_limit)
345
+
346
+
347
+ def _max_unit_variates(payload: ForecastRequest) -> int:
348
+ """Variates Precog sends to the single forward pass for this problem.
349
+
350
+ Targets and covariate channels share the same execution budget.
351
+ """
352
+ return (
353
+ len(payload.targets) + len(payload.past_covariates) + len(payload.known_future_covariates)
354
+ )
355
+
356
+
357
+ def _enforce_limits(payload: ForecastRequest, settings: Settings, engine: Engine) -> None:
358
+ if payload.horizon > settings.max_horizon:
359
+ raise HTTPException(
360
+ status_code=422,
361
+ detail=f"horizon {payload.horizon} exceeds max {settings.max_horizon}",
362
+ )
363
+ if len(payload.targets) > settings.max_series:
364
+ raise HTTPException(
365
+ status_code=422,
366
+ detail=f"{len(payload.targets)} targets exceed max {settings.max_series}",
367
+ )
368
+ effective_context = _min_limit(settings.max_context, engine.max_context)
369
+ longest = max(len(target.values) for target in payload.targets)
370
+ if longest > effective_context:
371
+ raise HTTPException(
372
+ status_code=422,
373
+ detail=(
374
+ f"context length {longest} exceeds max {effective_context}; "
375
+ "Precog never truncates input to fit the model context"
376
+ ),
377
+ )
378
+ # The target policy ceiling and the backend execution budget are distinct:
379
+ # targets + covariate channels share the engine's forward-pass budget.
380
+ if engine.max_variates is not None:
381
+ variates = _max_unit_variates(payload)
382
+ if variates > engine.max_variates:
383
+ raise HTTPException(
384
+ status_code=422,
385
+ detail=(
386
+ f"{variates} variates exceed max {engine.max_variates}; "
387
+ "Precog never drops or chunks covariates/targets to fit the model"
388
+ ),
389
+ )
390
+ _enforce_supported_quantiles(payload, engine)
391
+
392
+
393
+ def _enforce_supported_quantiles(payload: ForecastRequest, engine: Engine) -> None:
394
+ """Reject requested quantile levels the active runtime cannot produce.
395
+
396
+ The runtime grid is the execution source of truth (#179); unsupported levels
397
+ fail closed before any backend column lookup.
398
+ """
399
+ supported = engine.quantile_levels
400
+ for level in payload.quantiles:
401
+ if not any(abs(level - candidate) < 1e-9 for candidate in supported):
402
+ allowed = ", ".join(f"{value:g}" for value in supported)
403
+ raise HTTPException(
404
+ status_code=422,
405
+ detail=f"unsupported quantile {level}; supported levels: {allowed}",
406
+ )
407
+
408
+
409
+ def _status_title(status_code: int) -> str:
410
+ return {
411
+ 401: "Unauthorized",
412
+ 422: "Unprocessable Entity",
413
+ 429: "Too Many Requests",
414
+ 504: "Gateway Timeout",
415
+ }.get(status_code, "Error")
416
+
417
+
418
+ def _format_errors(exc: RequestValidationError) -> str:
419
+ try:
420
+ return "; ".join(
421
+ f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}" for err in exc.errors()
422
+ )
423
+ except (KeyError, TypeError):
424
+ return "invalid request"
425
+
426
+
427
+ __all__ = ["ProblemDetail", "create_app", "load_engine"]
@@ -0,0 +1,49 @@
1
+ """Runtime configuration, loaded from ``PRECOG_*`` environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+
10
+ class Settings(BaseSettings):
11
+ """API settings.
12
+
13
+ Every field can be overridden with a ``PRECOG_``-prefixed env var, e.g.
14
+ ``PRECOG_MAX_HORIZON=512``.
15
+ """
16
+
17
+ model_config = SettingsConfigDict(env_prefix="PRECOG_", env_file=".env", extra="ignore")
18
+
19
+ engine: Literal["fake", "timesfm3"] = "timesfm3"
20
+ device: str = "cpu"
21
+ model_path: str = "/opt/precog/models"
22
+ model_id: str = "google/timesfm-3.0-pytorch"
23
+ model_revision: str | None = None
24
+ cache_dir: str | None = None
25
+ local_files_only: bool = False
26
+ preload: Literal["auto", "always", "never"] = "auto"
27
+ preload_retries: int = 3
28
+ model_required: bool = True
29
+ prune_old_revisions: bool = False
30
+ hf_token: str | None = None
31
+ per_core_batch_size: int = 16
32
+ torch_threads: int = 0
33
+ max_concurrency: int = 1
34
+ request_timeout_s: float = 300.0
35
+ api_key: str | None = None
36
+ enable_docs: bool = True
37
+ log_json: bool = True
38
+ otel_enabled: bool = False
39
+ otel_service_name: str = "precog-api"
40
+ rate_limit_requests: int = 0
41
+ rate_limit_window_s: int = 60
42
+
43
+ max_horizon: int = 1024
44
+ max_context: int = 16384
45
+ max_series: int = 64
46
+
47
+ @property
48
+ def model_name(self) -> str:
49
+ return "timesfm-3.0"