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.
- precog_api-1.0.0/.gitignore +25 -0
- precog_api-1.0.0/PKG-INFO +65 -0
- precog_api-1.0.0/README.md +35 -0
- precog_api-1.0.0/pyproject.toml +48 -0
- precog_api-1.0.0/src/precog_api/__init__.py +5 -0
- precog_api-1.0.0/src/precog_api/__main__.py +17 -0
- precog_api-1.0.0/src/precog_api/app.py +427 -0
- precog_api-1.0.0/src/precog_api/config.py +49 -0
- precog_api-1.0.0/src/precog_api/engine.py +74 -0
- precog_api-1.0.0/src/precog_api/engine_timesfm3.py +211 -0
- precog_api-1.0.0/src/precog_api/execution.py +114 -0
- precog_api-1.0.0/src/precog_api/mapping.py +81 -0
- precog_api-1.0.0/src/precog_api/observability.py +65 -0
- precog_api-1.0.0/src/precog_api/tracing.py +44 -0
- precog_api-1.0.0/src/precog_api/warmup.py +148 -0
- precog_api-1.0.0/tests/test_app.py +201 -0
- precog_api-1.0.0/tests/test_effective_limits.py +172 -0
- precog_api-1.0.0/tests/test_engine_capabilities.py +55 -0
- precog_api-1.0.0/tests/test_execution_boundary.py +165 -0
- precog_api-1.0.0/tests/test_finite.py +36 -0
- precog_api-1.0.0/tests/test_integration_multivariate.py +98 -0
- precog_api-1.0.0/tests/test_mapping.py +73 -0
- precog_api-1.0.0/tests/test_observability.py +69 -0
- precog_api-1.0.0/tests/test_warmup.py +148 -0
|
@@ -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,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"
|