pipelex-api 0.71.0__py3-none-any.whl

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 (46) hide show
  1. pipelex_api/__init__.py +0 -0
  2. pipelex_api/api.toml +21 -0
  3. pipelex_api/api_config.py +144 -0
  4. pipelex_api/bundle.py +243 -0
  5. pipelex_api/disclosure.py +43 -0
  6. pipelex_api/error_types.py +80 -0
  7. pipelex_api/error_uri.py +45 -0
  8. pipelex_api/errors.py +130 -0
  9. pipelex_api/exception_handlers.py +693 -0
  10. pipelex_api/json_body.py +182 -0
  11. pipelex_api/limits.py +67 -0
  12. pipelex_api/main.py +221 -0
  13. pipelex_api/method_cache.py +241 -0
  14. pipelex_api/method_source.py +215 -0
  15. pipelex_api/middleware.py +209 -0
  16. pipelex_api/openapi_responses.py +186 -0
  17. pipelex_api/openapi_schema.py +83 -0
  18. pipelex_api/problem_document.py +134 -0
  19. pipelex_api/py.typed +0 -0
  20. pipelex_api/routes/__init__.py +23 -0
  21. pipelex_api/routes/health.py +24 -0
  22. pipelex_api/routes/pipelex/__init__.py +21 -0
  23. pipelex_api/routes/pipelex/agent/__init__.py +11 -0
  24. pipelex_api/routes/pipelex/agent/concept.py +60 -0
  25. pipelex_api/routes/pipelex/agent/models.py +49 -0
  26. pipelex_api/routes/pipelex/agent/pipe_spec.py +59 -0
  27. pipelex_api/routes/pipelex/build/__init__.py +11 -0
  28. pipelex_api/routes/pipelex/build/inputs.py +192 -0
  29. pipelex_api/routes/pipelex/build/output.py +163 -0
  30. pipelex_api/routes/pipelex/build/runner.py +236 -0
  31. pipelex_api/routes/pipelex/codegen.py +164 -0
  32. pipelex_api/routes/pipelex/crate_ops.py +331 -0
  33. pipelex_api/routes/pipelex/pipe_io.py +186 -0
  34. pipelex_api/routes/pipelex/pipeline.py +938 -0
  35. pipelex_api/routes/pipelex/resolve.py +81 -0
  36. pipelex_api/routes/pipelex/tools.py +111 -0
  37. pipelex_api/routes/pipelex/utils.py +6 -0
  38. pipelex_api/routes/pipelex/validate.py +473 -0
  39. pipelex_api/routes/version.py +51 -0
  40. pipelex_api/schemas/__init__.py +0 -0
  41. pipelex_api/schemas/models.py +653 -0
  42. pipelex_api/security.py +284 -0
  43. pipelex_api-0.71.0.dist-info/METADATA +188 -0
  44. pipelex_api-0.71.0.dist-info/RECORD +46 -0
  45. pipelex_api-0.71.0.dist-info/WHEEL +4 -0
  46. pipelex_api-0.71.0.dist-info/licenses/LICENSE +95 -0
@@ -0,0 +1,182 @@
1
+ """JSON request bodies, bounded in nesting depth before any parser reads them.
2
+
3
+ `json.loads` recurses once per nested array or object. Up to Python 3.13 a deeply nested body made it
4
+ raise `RecursionError` at a fixed count; from Python 3.14 the guard is the thread's actual stack size,
5
+ so the same body overflows on a small stack and parses on a large one, to be refused later for some
6
+ other reason or accepted. The server therefore bounds the nesting itself, before parsing: a body nested
7
+ deeper than `MAX_JSON_NESTING_DEPTH` answers the same 422 `InvalidJSON` on every interpreter and stack.
8
+
9
+ Every JSON body the server parses goes through `ensure_json_nesting_within_limit`, by one of two doors:
10
+
11
+ - A route that declares a typed body has it parsed by FastAPI. Its router is built with
12
+ `route_class=JsonBodyRoute`, which checks the body before FastAPI parses it, and
13
+ `tests/unit/test_json_body.py` fails when a body-declaring route of the app is not one.
14
+ - A route that reads its raw body, as the run routes do, parses it with `decode_json_body`.
15
+ """
16
+
17
+ import json
18
+ import re
19
+ from collections.abc import Callable, Coroutine
20
+ from itertools import accumulate, cycle
21
+ from operator import mul
22
+ from typing import Any
23
+
24
+ from fastapi import Request, Response, params
25
+ from fastapi.routing import APIRoute
26
+ from typing_extensions import override
27
+
28
+ from pipelex_api.error_types import ErrorType
29
+ from pipelex_api.errors import raise_validation_error
30
+ from pipelex_api.limits import MAX_JSON_NESTING_DEPTH
31
+
32
+ # The two escape sequences that can hide a quote. Once they are gone, every quote left delimits a string.
33
+ _ESCAPED_BACKSLASH = b"\\\\"
34
+ _ESCAPED_QUOTE = b'\\"'
35
+ # Only quotes and brackets matter to nesting, and an object nests like an array.
36
+ _NOT_STRUCTURAL = bytes(byte for byte in range(256) if byte not in b'"[]{}')
37
+ _BRACES_AS_BRACKETS = bytes.maketrans(b"{}", b"[]")
38
+ _ADJACENT_QUOTES = b'""'
39
+ _QUOTE = b'"'
40
+ _INNERMOST_PAIR = b"[]"
41
+ _OPEN_BRACKET = ord("[")
42
+ _BRACKET_RUN = re.compile(rb"\[+|\]+")
43
+ # Peeling stops once a pass removes less than this fraction of the brackets the scan started with.
44
+ _PEEL_STOP_DIVISOR = 8
45
+ # The stages that build a list per piece, splitting at quotes and measuring runs, work a chunk at a time,
46
+ # so no list grows with the body and its memory stays bounded however the body is shaped.
47
+ _CHUNK_BYTES = 1 << 20
48
+
49
+
50
+ def _as_utf8(body: bytes) -> bytes:
51
+ """Return the body in UTF-8, the encoding the scan reads.
52
+
53
+ `json.loads` given bytes also accepts UTF-16 and UTF-32, which FastAPI passes it as they come, and in
54
+ which a quote or an escape is not the single byte the scan looks for. Such a body is transcoded the way
55
+ `json.loads` decodes it. A body that does not decode is answered empty: the parser fails on the same
56
+ bytes before it reads a single bracket.
57
+ """
58
+ encoding = json.detect_encoding(body)
59
+ if encoding in {"utf-8", "utf-8-sig"}:
60
+ return body
61
+ try:
62
+ return body.decode(encoding, "surrogatepass").encode("utf-8", "surrogatepass")
63
+ except UnicodeError:
64
+ return b""
65
+
66
+
67
+ def _brackets_outside_strings(body: bytes) -> bytes:
68
+ r"""Return the body's brackets that lie outside strings, in order, with every brace written as a bracket.
69
+
70
+ The escapes that can hide a quote are dropped first: removing every `\\` and then every `\"`, each
71
+ left to right, pairs a run of backslashes the way a parser does. The body is then cut down to its quotes
72
+ and brackets. Adjacent quotes enclose no bracket, whether they open and close one string or close one and
73
+ open the next, so dropping them changes nothing and spares the split below a piece per short string.
74
+ With every quote left a delimiter, the pieces between quotes alternate outside and inside strings,
75
+ starting outside, and the outside ones hold the brackets that nest. The skeleton is split a chunk at a
76
+ time, each chunk starting inside a string when the quotes before it are odd in number. An unterminated
77
+ string runs to the end, as it does for a parser.
78
+ """
79
+ unescaped = body.replace(_ESCAPED_BACKSLASH, b"").replace(_ESCAPED_QUOTE, b"")
80
+ skeleton = unescaped.translate(_BRACES_AS_BRACKETS, _NOT_STRUCTURAL).replace(_ADJACENT_QUOTES, b"")
81
+ outside: list[bytes] = []
82
+ in_string = False
83
+ for start in range(0, len(skeleton), _CHUNK_BYTES):
84
+ pieces = skeleton[start : start + _CHUNK_BYTES].split(_QUOTE)
85
+ outside.append(b"".join(pieces[1 if in_string else 0 :: 2]))
86
+ # A chunk split into an even number of pieces holds an odd number of quotes.
87
+ in_string ^= len(pieces) % 2 == 0
88
+ return b"".join(outside)
89
+
90
+
91
+ def json_nesting_exceeds(body: bytes, *, max_depth: int) -> bool:
92
+ """Tell whether a JSON body nests its arrays and objects more than `max_depth` levels deep, without parsing it.
93
+
94
+ The answer is exact for a valid JSON document. For an invalid one it is never lower than the depth a
95
+ parser reaches before it stops, so no body this lets through makes `json.loads` recurse past `max_depth`.
96
+
97
+ It costs time linear in the body and never loops in Python over its bytes: a multi-megabyte body is
98
+ mostly strings, which `bytes` operations skip at C speed, and the brackets left are measured in two
99
+ stages. First, each pass of `replace(b"[]", b"")` removes the innermost level of every array and object
100
+ at once, which leaves the depth of every remaining bracket unchanged and lowers the deepest point by
101
+ exactly one. Passes continue while they remove much, which settles flat and shallow documents
102
+ outright. Then the runs of opening and closing brackets that remain, few by then, are summed in turn,
103
+ and the deepest running total plus the levels peeled is the body's depth.
104
+ """
105
+ brackets = _brackets_outside_strings(_as_utf8(body))
106
+ stop_below = len(brackets) // _PEEL_STOP_DIVISOR
107
+ peeled = 0
108
+ while brackets:
109
+ shorter = brackets.replace(_INNERMOST_PAIR, b"")
110
+ removed = len(brackets) - len(shorter)
111
+ if not removed:
112
+ break
113
+ brackets = shorter
114
+ peeled += 1
115
+ if peeled > max_depth:
116
+ return True
117
+ if removed < stop_below:
118
+ break
119
+ depth = 0
120
+ for start in range(0, len(brackets), _CHUNK_BYTES):
121
+ chunk = brackets[start : start + _CHUNK_BYTES]
122
+ # Maximal runs alternate between opening and closing brackets, so their signs alternate too.
123
+ signs = cycle((1, -1) if chunk[0] == _OPEN_BRACKET else (-1, 1))
124
+ running = list(accumulate(map(mul, map(len, _BRACKET_RUN.findall(chunk)), signs), initial=depth))
125
+ if max(running) + peeled > max_depth:
126
+ return True
127
+ depth = running[-1]
128
+ return False
129
+
130
+
131
+ def ensure_json_nesting_within_limit(body: bytes) -> None:
132
+ """Refuse a request body nested deeper than `MAX_JSON_NESTING_DEPTH` with a 422 `InvalidJSON`."""
133
+ if json_nesting_exceeds(body, max_depth=MAX_JSON_NESTING_DEPTH):
134
+ raise_validation_error(
135
+ message=f"Request body is nested too deeply: its JSON arrays and objects may nest at most {MAX_JSON_NESTING_DEPTH} levels.",
136
+ error_type=ErrorType.INVALID_JSON,
137
+ )
138
+
139
+
140
+ def decode_json_body(body: bytes) -> Any:
141
+ """Parse a raw request body as plain JSON, refusing it with a 422 `InvalidJSON` when it cannot be.
142
+
143
+ For a route that reads its body itself rather than declaring a typed one. The refusals are caller
144
+ mistakes, never a sanitized 500:
145
+ - a body nested deeper than `MAX_JSON_NESTING_DEPTH`, checked before parsing;
146
+ - `UnicodeDecodeError`: the bytes are not valid UTF-8;
147
+ - `ValueError`: `json.JSONDecodeError`, which subclasses it;
148
+ - `RecursionError`: the depth check leaves the parser nothing deep enough to raise it, so this only
149
+ stands as a second line of defence.
150
+ """
151
+ ensure_json_nesting_within_limit(body)
152
+ try:
153
+ return json.loads(body.decode("utf-8"))
154
+ except (UnicodeDecodeError, ValueError, RecursionError) as exc:
155
+ raise_validation_error(
156
+ message=f"Request body is not valid JSON: {exc!s}",
157
+ error_type=ErrorType.INVALID_JSON,
158
+ )
159
+
160
+
161
+ class JsonBodyRoute(APIRoute):
162
+ """An `APIRoute` that checks a declared JSON body's nesting depth before FastAPI parses it.
163
+
164
+ FastAPI parses a typed body with `json.loads` and turns any failure but a `JSONDecodeError` into a
165
+ bare 400, so the check runs in front of FastAPI's own handler, where its 422 reaches the API's
166
+ exception handlers like any other `ApiError`. The body it reads is cached on the request, so FastAPI
167
+ does not read it twice. A route that declares no body, or a form body, is left as it is: one that
168
+ reads its raw body parses it with `decode_json_body`.
169
+ """
170
+
171
+ @override
172
+ def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
173
+ handler = super().get_route_handler()
174
+ if self.body_field is None or isinstance(self.body_field.field_info, params.Form):
175
+ # No body, or a form body, which FastAPI never hands to `json.loads`.
176
+ return handler
177
+
178
+ async def bounded_handler(request: Request) -> Response:
179
+ ensure_json_nesting_within_limit(await request.body())
180
+ return await handler(request)
181
+
182
+ return bounded_handler
pipelex_api/limits.py ADDED
@@ -0,0 +1,67 @@
1
+ """Centralized, env-tunable size limits for incoming requests.
2
+
3
+ Every endpoint that accepts user-supplied content bounds it via constants
4
+ imported from this module. Values are read once at import time — change
5
+ requires a process restart.
6
+ """
7
+
8
+ from pipelex import log
9
+ from pipelex.system.environment import get_optional_env
10
+
11
+ DEFAULT_MAX_REQUEST_BODY_MIB = 100
12
+ DEFAULT_MAX_MTHDS_FILE_KIB = 1024 # 1 MiB per .mthds file
13
+ DEFAULT_MAX_MTHDS_FILES_PER_REQUEST = 16
14
+ DEFAULT_MAX_PIPE_CODE_LEN = 256
15
+ MAX_METHOD_REF_LEN = 512 # `method_ref` selector strings; a fixed schema bound, not env-tunable
16
+ # How deep the arrays and objects of a JSON request body may nest, the body's own envelope included
17
+ # (`{"inputs": {"x": [1]}}` nests three levels). Every route checks it before parsing, with
18
+ # `pipelex_api.json_body`, because how deep `json.loads` can recurse depends on the interpreter and,
19
+ # from Python 3.14, on the thread's stack size. Real inputs nest a few dozen levels at most. The bound
20
+ # also sits well below pydantic's own JSON parser, which refuses a document nested past 200 levels and
21
+ # reads a run's inputs again downstream, a few envelope levels deeper, where a transported PipeFunc
22
+ # request and the trace event logs are parsed. A fixed bound, not env-tunable: raising it would hand the
23
+ # parser's stack back to the caller.
24
+ MAX_JSON_NESTING_DEPTH = 128
25
+ DEFAULT_MAX_CALLBACK_URLS = 5
26
+ DEFAULT_MAX_CALLBACK_URL_LEN = 2048
27
+ DEFAULT_MAX_AGENT_SPEC_KIB = 256 # 256 KiB for JSON concept/pipe specs
28
+ DEFAULT_MAX_BUNDLE_FILES = 128 # entries in a materialized method bundle (.mthds + .py + requirements.txt)
29
+ DEFAULT_MAX_BUNDLE_TOTAL_KIB = 8 * 1024 # 8 MiB decompressed across the whole bundle (zip-bomb guard)
30
+ DEFAULT_MAX_METHOD_CACHE_CLONES = 64 # cached method-package clones (one per resolved commit SHA)
31
+ DEFAULT_MAX_METHOD_CACHE_TOTAL_KIB = 512 * 1024 # 512 MiB across all cached clones
32
+ DEFAULT_MAX_METHOD_CACHE_AGE_HOURS = 24 # a cached clone unused for this long is evicted
33
+
34
+
35
+ def _read_positive_int(env_var: str, default: int) -> int:
36
+ raw = get_optional_env(env_var)
37
+ if not raw:
38
+ return default
39
+ try:
40
+ parsed = int(raw)
41
+ except ValueError:
42
+ log.warning(f"Invalid {env_var}={raw!r}, falling back to {default}")
43
+ return default
44
+ if parsed <= 0:
45
+ log.warning(f"{env_var} must be positive (got {parsed}), falling back to {default}")
46
+ return default
47
+ return parsed
48
+
49
+
50
+ MAX_REQUEST_BODY_MIB = _read_positive_int("MAX_REQUEST_BODY_MIB", DEFAULT_MAX_REQUEST_BODY_MIB)
51
+ MAX_REQUEST_BODY_BYTES = MAX_REQUEST_BODY_MIB * 1024 * 1024
52
+
53
+ MAX_MTHDS_FILE_BYTES = _read_positive_int("MAX_MTHDS_FILE_KIB", DEFAULT_MAX_MTHDS_FILE_KIB) * 1024
54
+ MAX_MTHDS_FILES_PER_REQUEST = _read_positive_int("MAX_MTHDS_FILES_PER_REQUEST", DEFAULT_MAX_MTHDS_FILES_PER_REQUEST)
55
+ MAX_PIPE_CODE_LEN = _read_positive_int("MAX_PIPE_CODE_LEN", DEFAULT_MAX_PIPE_CODE_LEN)
56
+
57
+ MAX_CALLBACK_URLS = _read_positive_int("MAX_CALLBACK_URLS", DEFAULT_MAX_CALLBACK_URLS)
58
+ MAX_CALLBACK_URL_LEN = _read_positive_int("MAX_CALLBACK_URL_LEN", DEFAULT_MAX_CALLBACK_URL_LEN)
59
+
60
+ MAX_AGENT_SPEC_BYTES = _read_positive_int("MAX_AGENT_SPEC_KIB", DEFAULT_MAX_AGENT_SPEC_KIB) * 1024
61
+
62
+ MAX_BUNDLE_FILES = _read_positive_int("MAX_BUNDLE_FILES", DEFAULT_MAX_BUNDLE_FILES)
63
+ MAX_BUNDLE_TOTAL_BYTES = _read_positive_int("MAX_BUNDLE_TOTAL_KIB", DEFAULT_MAX_BUNDLE_TOTAL_KIB) * 1024
64
+
65
+ MAX_METHOD_CACHE_CLONES = _read_positive_int("MAX_METHOD_CACHE_CLONES", DEFAULT_MAX_METHOD_CACHE_CLONES)
66
+ MAX_METHOD_CACHE_TOTAL_BYTES = _read_positive_int("MAX_METHOD_CACHE_TOTAL_KIB", DEFAULT_MAX_METHOD_CACHE_TOTAL_KIB) * 1024
67
+ MAX_METHOD_CACHE_AGE_SECONDS = _read_positive_int("MAX_METHOD_CACHE_AGE_HOURS", DEFAULT_MAX_METHOD_CACHE_AGE_HOURS) * 3600
pipelex_api/main.py ADDED
@@ -0,0 +1,221 @@
1
+ """FastAPI app init, middleware, and router registration.
2
+
3
+ App construction lives here; the failure → HTTP response mapping lives in
4
+ `pipelex_api.exception_handlers` (registered against this app below). Routes
5
+ therefore no longer need to catch and shape errors themselves — anything
6
+ they raise lands in the right handler by exception class.
7
+ """
8
+
9
+ from collections.abc import AsyncGenerator
10
+ from contextlib import asynccontextmanager
11
+ from importlib.metadata import PackageNotFoundError
12
+ from importlib.metadata import version as package_version
13
+
14
+ from fastapi import Depends, FastAPI
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+ from mthds.protocol.protocol import PROTOCOL_VERSION
17
+ from pipelex.interpreter_plugins.builtins import BUILTIN_PLUGINS, CORE_UNCONDITIONAL_PLUGIN_NAMES, ENTRY_POINT_GROUPS
18
+ from pipelex.pipelex import Pipelex
19
+ from pipelex.plugins.discovery import build_registrar
20
+ from pipelex.plugins.registrar import HttpErrorMapperFn
21
+ from pipelex.system.configuration.config_loader import config_manager
22
+ from pipelex.system.configuration.configs import PipelexConfig
23
+ from pipelex.system.environment import get_optional_env
24
+ from pipelex.system.runtime import IntegrationMode
25
+ from pydantic import BaseModel, Field
26
+ from starlette.middleware.base import BaseHTTPMiddleware
27
+
28
+ from pipelex_api.api_config import get_api_config, resolve_boot_orchestrator
29
+ from pipelex_api.disclosure import resolve_disclosure_mode
30
+ from pipelex_api.exception_handlers import register_exception_handlers
31
+ from pipelex_api.middleware import RequestIdMiddleware, request_body_size_middleware
32
+ from pipelex_api.openapi_schema import PipelexFastAPI
33
+ from pipelex_api.routes import router as api_router
34
+ from pipelex_api.routes.health import router as health_router
35
+ from pipelex_api.routes.version import router as version_router
36
+ from pipelex_api.security import get_auth_dependency
37
+
38
+
39
+ @asynccontextmanager
40
+ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
41
+ # Resolve the deployment's orchestration mode BEFORE booting, so the process can boot
42
+ # under the matching orchestrator. `orchestration_mode` selects the dispatch arm; a
43
+ # non-`direct` (async/boot) orchestrator — e.g. "temporal" — must additionally claim the
44
+ # process-global execution hub slots at boot, which the shared WorkflowExecutor requires
45
+ # (`is_*_boot_active()`): without it, a `temporal`-mode runner resolves the Temporal
46
+ # dispatch arm but the underlying pipe-run stack is still in-process, and dispatch fails
47
+ # with AsyncExecutionNotEnabledError. The base `direct` mode names no orchestrator and
48
+ # boots in-process (boot_orchestrator=None). `resolve_boot_orchestrator` derives boot from
49
+ # the deployment config and refuses a config whose single boot can't service its own
50
+ # per-request override policy (a `direct` default with override on) — so boot and dispatch
51
+ # can never be set inconsistently.
52
+ #
53
+ # Loading `api.toml` here also fails the app fast on a malformed config / baked override
54
+ # (the same posture as ERROR_DISCLOSURE), now even before the singleton exists. The loader
55
+ # only needs `runtime_manager.environment` (from PIPELEX_ENV), which resolves without a
56
+ # live singleton. get_api_config() is @cache'd, so the warm here is reused everywhere.
57
+ boot_orchestrator = resolve_boot_orchestrator(get_api_config())
58
+ Pipelex.make(integration_mode=IntegrationMode.FASTAPI, boot_orchestrator=boot_orchestrator)
59
+ try:
60
+ yield
61
+ finally:
62
+ Pipelex.teardown_if_needed()
63
+
64
+
65
+ def _resolve_cors_origins() -> tuple[list[str], bool]:
66
+ """Read CORS_ALLOW_ORIGINS env var. Returns (origins, allow_credentials).
67
+
68
+ Default: wildcard origins, credentials disabled — the only valid combination
69
+ when origins is `*` (browsers reject credentials with wildcard). To enable
70
+ credentials, set CORS_ALLOW_ORIGINS to a comma-separated allowlist.
71
+ """
72
+ raw = get_optional_env("CORS_ALLOW_ORIGINS")
73
+ if not raw or raw.strip() == "*":
74
+ return ["*"], False
75
+ origins = [origin.strip() for origin in raw.split(",") if origin.strip()]
76
+ if not origins:
77
+ return ["*"], False
78
+ return origins, True
79
+
80
+
81
+ # Resolve and validate ERROR_DISCLOSURE once, at module/startup: an unrecognized
82
+ # value raises here and the production app fails to boot rather than silently
83
+ # defaulting. Passed into `register_exception_handlers` below; the resolved
84
+ # mode drives how much of an error report reaches a client. Kept module-level
85
+ # so the production fail-fast lives on this single import path — only this
86
+ # module triggers it, not `pipelex_api.exception_handlers` (which lets tests register
87
+ # the handlers without inheriting the env-validation crash).
88
+ ERROR_DISCLOSURE_MODE = resolve_disclosure_mode()
89
+
90
+
91
+ def _resolve_http_error_mappers() -> dict[type[Exception], HttpErrorMapperFn]:
92
+ """Resolve the orchestrator plugins' HTTP-error mappers for this deployment.
93
+
94
+ Runs the pure, repeatable `build_registrar` against the loaded config (the same
95
+ standalone pattern `pipelex plugins list` uses) and reads back the
96
+ `{exc_type: to_error_report}` map each installed orchestrator plugin contributed
97
+ via `PluginRegistrar.add_http_error_mapper`. The base installs no orchestrator
98
+ plugin, so this is an empty map and no transport-error handler is registered; a
99
+ flavor (e.g. the Temporal one) contributes its plugin's mapper here, and only at
100
+ this point — at app construction, where the plugin (and therefore its SDK) is by
101
+ definition installed — is the plugin's exc-type provider thunk run. Resolved once
102
+ at module import so a duplicate/broken plugin fails the app fast, mirroring the
103
+ `ERROR_DISCLOSURE` fail-fast above.
104
+
105
+ Safe at import because the map is a pure function of *installed* plugins (entry
106
+ points + `config.runtime.plugins.disabled`) — never of the `boot_orchestrator` that
107
+ `Pipelex.make` selects at boot — so an import-time resolution yields the identical
108
+ map a post-boot one would. `boot_orchestrator=None` is passed for exactly that
109
+ reason: it gates only a plugin's hub-slot claims, which this throwaway registrar
110
+ never applies, while the HTTP-error mapper a plugin contributes is unconditional.
111
+ Naming the deployment's real orchestrator here would change nothing in the map and
112
+ would drag `api.toml` loading onto the import path.
113
+
114
+ `load_config_validated` rather than a load followed by a `model_validate`, because
115
+ that is the tolerant entry point the boot itself uses: a configuration a pipelex
116
+ schema change left behind is replayed through its migration ledger in memory and
117
+ re-validated, so a stale-but-explainable config warns instead of stopping the boot.
118
+ Validating the raw dict here would refuse, at import, a config the very next step
119
+ (`Pipelex.make` in `lifespan`) accepts — the app would never start over a file
120
+ pipelex offers to migrate.
121
+ """
122
+ config = config_manager.load_config_validated(config_cls=PipelexConfig)
123
+ return build_registrar(
124
+ config=config,
125
+ boot_orchestrator=None,
126
+ builtin_plugins=BUILTIN_PLUGINS,
127
+ core_unconditional_plugin_names=CORE_UNCONDITIONAL_PLUGIN_NAMES,
128
+ entry_point_groups=ENTRY_POINT_GROUPS,
129
+ ).get_http_error_mappers()
130
+
131
+
132
+ HTTP_ERROR_MAPPERS = _resolve_http_error_mappers()
133
+
134
+
135
+ def _own_version() -> str:
136
+ """This server package's version — best-effort for app metadata."""
137
+ try:
138
+ return package_version("pipelex-api")
139
+ except PackageNotFoundError:
140
+ return "0.0.0"
141
+
142
+
143
+ fastapi_app = PipelexFastAPI(
144
+ redirect_slashes=False,
145
+ lifespan=lifespan,
146
+ title="Pipelex API",
147
+ version=_own_version(),
148
+ summary=f"The source-available Pipelex runner — implements MTHDS Protocol v{PROTOCOL_VERSION}.",
149
+ description=(
150
+ f"This server implements the [MTHDS Protocol](https://mthds.ai) v{PROTOCOL_VERSION} "
151
+ "(`POST /execute`, `POST /start`, `POST /validate`, `GET /models`, `GET /version` — "
152
+ "marked `x-mthds-protocol: true`) plus the Pipelex API extensions: resolve and codegen "
153
+ "(`/resolve`, `/codegen`), build tooling (`/build/*`), and editor tooling (`/lint`, `/format`). "
154
+ "Contract layering: MTHDS Protocol ⊂ Pipelex API (this server) ⊂ Pipelex hosted API. "
155
+ "All endpoints are served under the `/v1` base path; "
156
+ "every error is an RFC 7807 `application/problem+json` problem document, documented per "
157
+ "operation as a `ProblemDocument`."
158
+ ),
159
+ license_info={"name": "Elastic License 2.0", "identifier": "Elastic-2.0"},
160
+ )
161
+
162
+ # Order matters: Starlette's `add_middleware` PREPENDS (see
163
+ # `user_middleware.insert(0, ...)` in `starlette.applications`), so the LAST
164
+ # `add_middleware` call becomes the OUTERMOST wrapper. Body-size is registered
165
+ # first so CORS ends up wrapping it: a 413 short-circuit from the body-size
166
+ # middleware still passes back through CORSMiddleware on the way out, so a
167
+ # cross-origin browser POST sees the RFC 7807 413 with the
168
+ # `Access-Control-Allow-Origin` header it needs — not a generic CORS error
169
+ # that swallows the response.
170
+ fastapi_app.add_middleware(BaseHTTPMiddleware, dispatch=request_body_size_middleware)
171
+
172
+ cors_origins, cors_allow_credentials = _resolve_cors_origins()
173
+ fastapi_app.add_middleware(
174
+ CORSMiddleware,
175
+ allow_origins=cors_origins,
176
+ allow_credentials=cors_allow_credentials,
177
+ allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
178
+ allow_headers=["*"],
179
+ expose_headers=["*"],
180
+ )
181
+
182
+ fastapi_app.include_router(health_router)
183
+
184
+ # `GET /v1/version` is the protocol handshake — ALWAYS public, mounted without
185
+ # the auth dependency exactly like `/health` (clients call it for feature
186
+ # detection before they have credentials).
187
+ fastapi_app.include_router(version_router, prefix="/v1")
188
+
189
+ # Register all other routes WITH authentication (auto-selects based on AUTH_MODE env var: none/jwt/api_key).
190
+ # The API mounts at `/v1` — the SDKs compose `{MTHDS_BASE_URL}/v1/{endpoint}` (master D10); no `/api/v1`
191
+ # mount and no alias remain.
192
+ auth_dependency = get_auth_dependency()
193
+ fastapi_app.include_router(api_router, prefix="/v1", dependencies=[Depends(auth_dependency)])
194
+
195
+
196
+ class ServiceIdentity(BaseModel):
197
+ """Body of `GET /` — the service identity banner."""
198
+
199
+ message: str = Field(..., description="Name of the service answering on this origin.")
200
+
201
+
202
+ @fastapi_app.get("/", summary="Service identity banner", tags=["health"])
203
+ async def root() -> ServiceIdentity:
204
+ """Identify the service answering on this origin. No auth required.
205
+
206
+ A human-facing banner for someone who lands on the bare origin — not a
207
+ liveness probe (that is `GET /health`) and not the protocol handshake
208
+ (`GET /v1/version`). It reads nothing and can only succeed.
209
+ """
210
+ return ServiceIdentity(message="Pipelex API")
211
+
212
+
213
+ register_exception_handlers(fastapi_app, disclosure_mode=ERROR_DISCLOSURE_MODE, http_error_mappers=HTTP_ERROR_MAPPERS)
214
+
215
+
216
+ # RequestIdMiddleware wraps the *entire* FastAPI app — including Starlette's
217
+ # ServerErrorMiddleware, which `add_middleware` could only ever nest inside.
218
+ # This is what makes it genuinely outermost: the request-id contextvars are
219
+ # bound, and `X-Request-ID` is echoed, on every response — the catch-all 500
220
+ # included. `app` is the ASGI entrypoint (uvicorn loads `pipelex_api.main:app`).
221
+ app = RequestIdMiddleware(fastapi_app)