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,186 @@
1
+ """Documentation-only models and shared `responses=` declarations for the API's RFC 7807 errors.
2
+
3
+ Every failure this server emits is an `application/problem+json` document built
4
+ by the global handlers in `pipelex_api.exception_handlers` — from a pipelex
5
+ `ErrorReport` (`build_problem_document`) or from an API-authored `ApiError`
6
+ (`build_problem_document_from_api_error`). Neither path validates through a
7
+ Pydantic model: they build plain dicts. The models here exist **only** so the
8
+ committed OpenAPI artifact can publish that shape with a real schema instead of
9
+ an untyped `additionalProperties: true` blob — nothing in the request path
10
+ imports them, and adding a field here does not change a single response byte.
11
+ Keep them in step with `docs/error-responses.md`, which is the prose contract.
12
+
13
+ `ProblemDocument.validation_errors` reuses pipelex's own `ValidationErrorItem`,
14
+ so the item shape published on the error wire is the same one `/validate`'s 200
15
+ invalid arm publishes — including `suggested_fix`, which rides along for free.
16
+
17
+ The response dicts below are attached two ways:
18
+
19
+ - `COMMON_PROBLEM_RESPONSES` goes on the composite router in `pipelex_api.routes`, so
20
+ every auth-wrapped `/v1` operation documents the four failures any of them can
21
+ produce. Declaring a `422` there also suppresses FastAPI's automatic
22
+ `HTTPValidationError` response, which advertised the wrong media type and the
23
+ wrong schema on nearly every route.
24
+ - The single-status constants go on the routes that can additionally produce
25
+ them (`responses=` on the decorator merges over the router-level set).
26
+
27
+ The media type is NOT set here. FastAPI renders a `responses` entry's `model`
28
+ under the route's own response-class media type (`application/json`), with no
29
+ per-response override; `PipelexFastAPI.openapi()` in `pipelex_api.openapi_schema`
30
+ re-keys every 4xx/5xx response onto `application/problem+json` (via
31
+ `use_problem_json_media_type`) after the schema is generated.
32
+ """
33
+
34
+ from typing import Any
35
+
36
+ from pipelex.base_exceptions import ValidationErrorItem
37
+ from pipelex.cogt.inference.error_classification import ProviderErrorMetadata, UserAction
38
+ from pydantic import BaseModel, ConfigDict, Field
39
+
40
+
41
+ class ProblemDocument(BaseModel):
42
+ """An RFC 7807 `application/problem+json` error body — the shape of every failure response.
43
+
44
+ Standard RFC 7807 members (`type`, `title`, `status`, `detail`, `instance`)
45
+ plus the extension members pipelex's classification adds. Only `type`,
46
+ `title`, `status`, `detail`, and `error_type` are always present: the rest
47
+ ride along when the originating error populates them, so a consumer must
48
+ treat them as optional. `extra="allow"` mirrors RFC 7807's open-ended
49
+ extension-member rule.
50
+ """
51
+
52
+ model_config = ConfigDict(extra="allow")
53
+
54
+ type: str = Field(..., description="Stable URI for the error class: `https://docs.pipelex.com/latest/errors/<kebab-class-name>/`.")
55
+ title: str = Field(..., description="Short, human-readable summary of the error class.")
56
+ status: int = Field(..., description="HTTP status code, repeated in the body per RFC 7807.")
57
+ detail: str = Field(..., description="Human-readable explanation of this specific failure. Redacted under `ERROR_DISCLOSURE=strict`.")
58
+ instance: str | None = Field(default=None, description="Path of the request that failed.")
59
+ request_id: str | None = Field(default=None, description="Correlation id, echoed in the `X-Request-ID` response header.")
60
+ error_type: str = Field(..., description="Stable class name of the originating error (e.g. `ValidateBundleError`, `Unauthenticated`).")
61
+ error_domain: str | None = Field(
62
+ default=None,
63
+ description="`input` (caller can fix it → 422), `config` or `runtime` (deployment must fix it → 500). Absent for domain-less errors.",
64
+ )
65
+ retryable: bool | None = Field(
66
+ default=None,
67
+ description=(
68
+ "Whether retrying the same request can plausibly succeed. Always present on API-authored errors; "
69
+ "on pipelex errors only when the originating error classifies it."
70
+ ),
71
+ )
72
+ error_category: str | None = Field(default=None, description="Finer classification, when the originating error provides one (inference errors).")
73
+ user_action: UserAction | None = Field(default=None, description="Structured suggestion of what the caller should do next, when available.")
74
+ model: str | None = Field(default=None, description="Inference model that failed. Stripped under `ERROR_DISCLOSURE=strict`.")
75
+ provider: str | None = Field(default=None, description="Inference provider that failed. Stripped under `ERROR_DISCLOSURE=strict`.")
76
+ provider_metadata: ProviderErrorMetadata | None = Field(
77
+ default=None,
78
+ description="Upstream provider error metadata. Stripped (bar a curated slice) under `ERROR_DISCLOSURE=strict`.",
79
+ )
80
+ validation_errors: list[ValidationErrorItem] | None = Field(
81
+ default=None,
82
+ description=(
83
+ "Structured per-error diagnostics, carried by a `ValidateBundleError`. Each item may carry a `suggested_fix`. "
84
+ "Retained under `ERROR_DISCLOSURE=strict` — it describes the caller's own bundle, not server internals. "
85
+ "On `/validate`, `/resolve`, `/codegen` and `/build/*` an invalid bundle is a **200** verdict instead, so the "
86
+ "items ride the response body there rather than a problem document."
87
+ ),
88
+ )
89
+
90
+
91
+ def _problem(description: str, *, headers: dict[str, Any] | None = None) -> dict[str, Any]:
92
+ """Build one `responses=` entry documenting a `ProblemDocument` failure.
93
+
94
+ `model` registers `ProblemDocument` in the artifact's components and emits a
95
+ `$ref` to it; `PipelexFastAPI.openapi()` (`pipelex_api.openapi_schema`) then moves
96
+ the schema onto the `application/problem+json` media type.
97
+ """
98
+ response: dict[str, Any] = {"description": description, "model": ProblemDocument}
99
+ if headers is not None:
100
+ response["headers"] = headers
101
+ return response
102
+
103
+
104
+ PROBLEM_400_START_REQUIRES_ASYNC: dict[str, Any] = _problem(
105
+ "`StartRequiresAsyncOrchestration` — this deployment's orchestrator is blocking-only and cannot honor "
106
+ "fire-and-forget delivery. Use `POST /execute` instead.",
107
+ )
108
+
109
+ PROBLEM_401: dict[str, Any] = _problem(
110
+ "Missing or invalid bearer token. Only reachable when the deployment enables auth (`AUTH_MODE=api_key` or `AUTH_MODE=jwt`).",
111
+ headers={
112
+ "WWW-Authenticate": {
113
+ "description": "Authentication challenge — always `Bearer`.",
114
+ "schema": {"type": "string"},
115
+ }
116
+ },
117
+ )
118
+
119
+ PROBLEM_403_RUN_POLICY: dict[str, Any] = _problem(
120
+ "A deployment-policy refusal: `OrchestrationModeOverrideForbidden` — the request asked for an "
121
+ "`orchestration_mode` this deployment does not allow overriding per request "
122
+ "(`allow_request_orchestration_mode_override = false`); `CustomCodeRequiresSandbox` — the method (a bundle, or a "
123
+ "fetched `method_ref` package) ships custom Python and this deployment is not sandbox-hosted; or "
124
+ "`MethodStructuresRefusedError` — the method's Python declares structure classes (`StructuredContent` "
125
+ "subclasses), whether it arrived as a bundle (`files` or `bundle_b64`) or as a fetched `method_ref` package: "
126
+ "a sandbox-hosted deployment imports no caller Python into its own process, so it refuses them before "
127
+ "loading anything (express the types as MTHDS concepts instead).",
128
+ )
129
+
130
+ PROBLEM_409_DUPLICATE_RUN: dict[str, Any] = _problem(
131
+ "`PipelineManagerAlreadyExistsError` — the submitted `pipeline_run_id` is still registered for an in-flight run. "
132
+ "Completed and failed runs free their id, so this only fires for genuinely concurrent duplicates.",
133
+ )
134
+
135
+ PROBLEM_413: dict[str, Any] = _problem(
136
+ "Request body exceeds the deployment's size limit (`MAX_REQUEST_BODY_MIB`, 100 MiB by default).",
137
+ )
138
+
139
+ PROBLEM_422: dict[str, Any] = _problem(
140
+ "The request could not be processed: a malformed body, a field failing validation, or an `input`-domain pipelex "
141
+ "error (a `.mthds` bundle the caller must fix). Note that on the diagnostic routes an *invalid bundle* is a **200** "
142
+ "verdict, not a 422 — see each route's response contract.",
143
+ )
144
+
145
+ PROBLEM_429: dict[str, Any] = _problem(
146
+ "An upstream inference provider rate-limited the run. Passed through from the provider; `Retry-After` is set when the provider supplied a hint.",
147
+ headers={
148
+ "Retry-After": {
149
+ "description": "Seconds to wait before retrying, when the upstream provider supplied a hint.",
150
+ "schema": {"type": "integer"},
151
+ }
152
+ },
153
+ )
154
+
155
+ PROBLEM_500: dict[str, Any] = _problem(
156
+ "A `config`-domain or `runtime`-domain failure the caller cannot fix (a missing env var, a bad TOML override, a "
157
+ "backend fault), or an unclassified error sanitized by the catch-all handler. Report the `request_id`.",
158
+ )
159
+
160
+ PROBLEM_501_ASYNC_NOT_ENABLED: dict[str, Any] = _problem(
161
+ "`AsyncExecutionNotEnabledError` — this deployment does not provide async pipeline execution. Permanent under the "
162
+ "current deployment; do not retry.",
163
+ )
164
+
165
+ PROBLEM_404_METHOD_PACKAGE: dict[str, Any] = _problem(
166
+ "`MethodPackageNotFoundError` — the `method_ref` repository was fetched, but no package in it matches the "
167
+ "requested address by manifest identity. The message lists the packages the repository does contain.",
168
+ )
169
+
170
+ PROBLEM_501_METHOD_REF: dict[str, Any] = _problem(
171
+ "`MethodRefNotSupported` — the request selected its closure by a **registry-form** `method_ref` (not a "
172
+ "`github.com/...` address), and no server-side method registry resolves those yet. Use an address-form reference "
173
+ "or submit inline `files[]` instead.",
174
+ )
175
+
176
+
177
+ # Attached to the composite `/v1` router (`pipelex_api.routes`), so every auth-wrapped operation documents
178
+ # the failures any of them can produce: the router-level auth check (401), the body-size middleware
179
+ # (413), request-shape and input-domain rejections (422), and the server-fault floor (500). Declaring
180
+ # the 422 here is also what suppresses FastAPI's automatic `HTTPValidationError` response.
181
+ COMMON_PROBLEM_RESPONSES: dict[int | str, dict[str, Any]] = {
182
+ 401: PROBLEM_401,
183
+ 413: PROBLEM_413,
184
+ 422: PROBLEM_422,
185
+ 500: PROBLEM_500,
186
+ }
@@ -0,0 +1,83 @@
1
+ """The FastAPI subclass that publishes this API's error responses under the right media type.
2
+
3
+ Every failure the API emits is an RFC 7807 `application/problem+json` document — that is the
4
+ whole contract of `pipelex_api.exception_handlers`. But FastAPI renders a `responses` entry's `model`
5
+ under the **route's** response-class media type (`application/json` for every route here) and
6
+ offers no per-response override (`route_response_media_type` in `fastapi.openapi.utils`). Left
7
+ alone, the published artifact would therefore promise `application/json` on every error — a
8
+ contract the server never honors.
9
+
10
+ Hand-writing the media type into each `responses` entry would mean hand-writing a `$ref` too,
11
+ forfeiting FastAPI's component registration for `ProblemDocument` (`pipelex_api.openapi_responses`). So
12
+ the schema is generated normally and the error responses are re-keyed afterwards, in
13
+ `PipelexFastAPI.openapi()` — FastAPI's documented "Extending OpenAPI" seam, and the single place
14
+ the API asserts the error media type. It holds for the live `/openapi.json` and for the committed
15
+ YAML alike: both go through `openapi()`.
16
+
17
+ Deliberately import-side-effect-free, mirroring `pipelex_api.exception_handlers`: no env var reads, no app
18
+ construction, no router wiring. That is what lets a test build a production-faithful app —
19
+ one that renders the error media type the way the real server does — without dragging in
20
+ `pipelex_api.main`'s startup chain (`Pipelex.make`, `get_auth_dependency`, the `ERROR_DISCLOSURE`
21
+ fail-fast), so a misconfigured env var cannot crash collection of every module that needs the
22
+ app class.
23
+ """
24
+
25
+ from typing import Any
26
+
27
+ from fastapi import FastAPI
28
+ from typing_extensions import override
29
+
30
+ from pipelex_api.problem_document import PROBLEM_JSON_MEDIA_TYPE
31
+
32
+ # HTTP methods that carry an operation object in an OpenAPI path item. A path item can also hold
33
+ # non-operation keys (`parameters`, `summary`, `servers`), so the pass below iterates this set
34
+ # rather than every key it finds.
35
+ _OPENAPI_OPERATION_KEYS = frozenset({"get", "put", "post", "delete", "options", "head", "patch", "trace"})
36
+
37
+
38
+ def _is_error_status(status_code: str) -> bool:
39
+ """Whether an OpenAPI response key denotes a 4xx/5xx status.
40
+
41
+ Response keys are strings, and beyond the numeric ones OpenAPI allows the wildcard forms
42
+ (`4XX`, `5XX`) and `default`. This server declares only numeric statuses, so anything
43
+ non-numeric is left alone rather than guessed at.
44
+ """
45
+ return status_code.isdigit() and int(status_code) >= 400
46
+
47
+
48
+ def use_problem_json_media_type(schema: dict[str, Any]) -> None:
49
+ """Re-key every documented 4xx/5xx response onto `application/problem+json`, in place.
50
+
51
+ See the module docstring for why this is a post-pass rather than a per-response declaration.
52
+ """
53
+ for path_item in schema.get("paths", {}).values():
54
+ for method, operation in path_item.items():
55
+ if method not in _OPENAPI_OPERATION_KEYS:
56
+ continue
57
+ for status_code, response in operation.get("responses", {}).items():
58
+ if not _is_error_status(status_code):
59
+ continue
60
+ content: dict[str, Any] | None = response.get("content")
61
+ if content is None:
62
+ continue
63
+ json_content = content.pop("application/json", None)
64
+ if json_content is not None:
65
+ content[PROBLEM_JSON_MEDIA_TYPE] = json_content
66
+
67
+
68
+ class PipelexFastAPI(FastAPI):
69
+ """The app class. Stock FastAPI, extended only to publish errors as `application/problem+json`."""
70
+
71
+ @override
72
+ def openapi(self) -> dict[str, Any]:
73
+ """The OpenAPI schema, with every documented 4xx/5xx moved onto `application/problem+json`.
74
+
75
+ The early return keeps the rewrite a one-shot: the base builds the schema and caches it on
76
+ `self.openapi_schema`, returning that same dict, so mutating it in place fixes up the cached
77
+ copy and every later call short-circuits here.
78
+ """
79
+ if self.openapi_schema is not None:
80
+ return self.openapi_schema
81
+ schema = super().openapi()
82
+ use_problem_json_media_type(schema)
83
+ return schema
@@ -0,0 +1,134 @@
1
+ """RFC 7807 problem-document construction.
2
+
3
+ A pure module — no FastAPI imports, no I/O. It turns the two error sources of
4
+ the API into the single `application/problem+json` body shape clients see:
5
+
6
+ - `build_problem_document` — for a pipelex `ErrorReport`. Delegates to
7
+ `ErrorReport.to_problem_document(...)`: pipelex owns the field mapping
8
+ (standard RFC 7807 slots plus classification extension members) and the
9
+ `disclosure_mode` redaction. The API only chooses the disclosure mode and
10
+ supplies the request context.
11
+ - `build_problem_document_from_api_error` — for an API-authored 4xx that has
12
+ no `ErrorReport` behind it (`raise_validation_error` and friends in
13
+ `pipelex_api.errors`). Built from the static `ErrorType` enum; always `INPUT` domain.
14
+
15
+ Both return a plain `dict` the caller serializes as `application/problem+json`.
16
+ """
17
+
18
+ from typing import Any
19
+
20
+ from pipelex.base_exceptions import DisclosureMode, ErrorDomain, ErrorReport
21
+
22
+ from pipelex_api.error_types import ErrorType
23
+ from pipelex_api.error_uri import error_type_title, error_type_uri
24
+
25
+ # Content type for every error response — RFC 7807 problem documents. Lives
26
+ # here, the module that builds them, so the FastAPI app, the middleware, and
27
+ # the error handlers can all reference one constant without a circular import.
28
+ PROBLEM_JSON_MEDIA_TYPE = "application/problem+json"
29
+
30
+
31
+ def build_problem_document(
32
+ report: ErrorReport,
33
+ *,
34
+ instance: str | None,
35
+ request_id: str | None,
36
+ disclosure_mode: DisclosureMode,
37
+ ) -> dict[str, Any]:
38
+ """Build an RFC 7807 problem document from a pipelex `ErrorReport`.
39
+
40
+ Thin wrapper over `ErrorReport.to_problem_document(...)`: the field mapping
41
+ and the `disclosure_mode` redaction both live upstream in pipelex. The
42
+ API's only responsibility is to choose the disclosure mode and pass the
43
+ request context through.
44
+ """
45
+ return report.to_problem_document(
46
+ instance=instance,
47
+ request_id=request_id,
48
+ disclosure_mode=disclosure_mode,
49
+ )
50
+
51
+
52
+ def build_problem_document_from_api_error(
53
+ error_type: ErrorType,
54
+ message: str,
55
+ status: int,
56
+ *,
57
+ instance: str | None,
58
+ request_id: str | None,
59
+ error_domain: ErrorDomain | None = ErrorDomain.INPUT,
60
+ retryable: bool = False,
61
+ ) -> dict[str, Any]:
62
+ """Build an RFC 7807 problem document for an API-authored error.
63
+
64
+ Used by the `pipelex_api.errors` 4xx/5xx helpers, whose failures are the API's own
65
+ request validation, auth, or configuration checks rather than a pipelex
66
+ domain error — there is no `ErrorReport` to delegate to. The `type` URI and
67
+ `title` are derived from the static `ErrorType` enum the same way pipelex
68
+ derives them for its own classes, so the standard RFC 7807 slots and the
69
+ `error_type` / `error_domain` members line up with what a pipelex error
70
+ renders.
71
+
72
+ Extension-member parity with a pipelex `ErrorReport` is by-field, not
73
+ wholesale, with two distinct rules:
74
+
75
+ - `retryable` is emitted unconditionally here (every API-authored error is
76
+ non-retryable; see below). pipelex emits it only when the source error
77
+ populated it, so a non-inference pipelex report (`PipelexConfigError`,
78
+ `EnvVarNotFoundError`) renders with no `retryable`. Asymmetric by design:
79
+ the API knows the answer for every error it authors, and clients can
80
+ drive retry logic uniformly without branching on field presence.
81
+ - Classification fields pipelex sets only on classifiable failures
82
+ (`error_category`, `user_action`, `model`, `provider`, `provider_metadata`)
83
+ are NOT manufactured here. The same omission rule pipelex applies to its
84
+ own non-inference reports (`PipelexConfigError`, `EnvVarNotFoundError`
85
+ carry no `error_category` either) — surfacing a fake category for an
86
+ API-authored validation or auth failure would lie about what the API
87
+ actually classified.
88
+
89
+ `error_domain` defaults to `INPUT` — the caller can fix a 4xx. API-owned
90
+ 5xx (a missing secret, a misconfigured backend) pass `CONFIG`: an operator,
91
+ not the caller, fixes those. `None` omits the member entirely, matching how
92
+ a domain-less pipelex error renders.
93
+
94
+ `retryable` defaults to `False`: every error this builder authors is a
95
+ caller-input mistake or a server misconfiguration, neither of which a blind
96
+ retry fixes. Carried as a positional contract member rather than dropped on
97
+ `None`, so clients can drive retry logic uniformly without branching on
98
+ field presence.
99
+
100
+ `None`-valued context (`instance`, `request_id`) is dropped rather than
101
+ emitted as `null`, matching `ErrorReport.to_problem_document` semantics.
102
+ """
103
+ document: dict[str, Any] = {
104
+ "type": error_type_uri(error_type),
105
+ "title": error_type_title(error_type),
106
+ "status": status,
107
+ "detail": message,
108
+ }
109
+ if instance is not None:
110
+ document["instance"] = instance
111
+ if request_id is not None:
112
+ document["request_id"] = request_id
113
+ document["error_type"] = error_type
114
+ if error_domain is not None:
115
+ document["error_domain"] = error_domain
116
+ document["retryable"] = retryable
117
+ return document
118
+
119
+
120
+ def with_request_context(document: dict[str, Any], *, instance: str | None, request_id: str | None) -> dict[str, Any]:
121
+ """Return a copy of a problem document carrying the two request-scoped members.
122
+
123
+ The `pipelex_api.errors` helpers build their document where no `Request` is in hand — a validation
124
+ check deep inside a route, an auth refusal — so `instance` and `request_id` are stamped later,
125
+ by the handler that renders the response and does hold the request. A copy rather than an
126
+ in-place edit, because the `ApiError` a caller catches and inspects must read exactly as it was
127
+ raised. `None` is dropped rather than emitted as `null`, the rule every builder here follows.
128
+ """
129
+ stamped = dict(document)
130
+ if instance is not None:
131
+ stamped["instance"] = instance
132
+ if request_id is not None:
133
+ stamped["request_id"] = request_id
134
+ return stamped
pipelex_api/py.typed ADDED
File without changes
@@ -0,0 +1,23 @@
1
+ from fastapi import APIRouter
2
+
3
+ from pipelex_api.openapi_responses import COMMON_PROBLEM_RESPONSES
4
+
5
+ from .pipelex import router as pipelex_router
6
+
7
+ # NOTE: the version router is NOT composed here — `GET /version` is always
8
+ # public (protocol handshake), so `pipelex_api.main` mounts it under `/v1` directly,
9
+ # WITHOUT the auth dependency this composite router gets wrapped in.
10
+ #
11
+ # `responses=` here merges into EVERY operation composed below (FastAPI's
12
+ # `include_router` folds the including router's `responses` into each route's
13
+ # own, route-level entries winning on a status collision). It documents the four
14
+ # failures every auth-wrapped `/v1` route shares — 401 from the router-level auth
15
+ # dependency `pipelex_api.main` wraps this in, 413 from the body-size middleware, 422 for
16
+ # request-shape/input-domain rejections, 500 for the server-fault floor — so each
17
+ # route only has to declare the extra statuses it alone can produce. Declaring the
18
+ # 422 is also what suppresses FastAPI's automatic `HTTPValidationError` response,
19
+ # whose media type (`application/json`) and schema both contradict the RFC 7807
20
+ # document the global handlers actually emit.
21
+ router = APIRouter(responses=COMMON_PROBLEM_RESPONSES)
22
+
23
+ router.include_router(pipelex_router)
@@ -0,0 +1,24 @@
1
+ from fastapi import APIRouter
2
+ from pydantic import BaseModel, Field
3
+
4
+ router = APIRouter(tags=["health"])
5
+
6
+
7
+ class HealthResponse(BaseModel):
8
+ """Body of `GET /health`."""
9
+
10
+ status: str = Field(..., description="Always `ok` — the endpoint answers only when the process is serving.")
11
+ message: str = Field(..., description="Human-readable confirmation that the server is up.")
12
+
13
+
14
+ @router.get("/health", summary="Liveness probe")
15
+ async def get_health() -> HealthResponse:
16
+ """Report that this server process is up and serving. No auth required.
17
+
18
+ A pure liveness probe for load balancers and orchestrators: it touches no
19
+ dependency (no pipelex library load, no storage, no inference provider), so
20
+ a 200 means "the process is serving", never "the deployment is correctly
21
+ configured". It is mounted outside the `/v1` base path and outside the auth
22
+ dependency, and cannot fail.
23
+ """
24
+ return HealthResponse(status="ok", message="Pipelex API is running")
@@ -0,0 +1,21 @@
1
+ from fastapi import APIRouter
2
+
3
+ from .agent import router as agent_router
4
+ from .build import router as build_router
5
+ from .codegen import router as codegen_router
6
+ from .pipe_io import router as pipe_io_router
7
+ from .pipeline import router as pipeline_router
8
+ from .resolve import router as resolve_router
9
+ from .tools import router as tools_router
10
+ from .validate import router as validate_router
11
+
12
+ router = APIRouter()
13
+
14
+ router.include_router(build_router)
15
+ router.include_router(pipeline_router)
16
+ router.include_router(validate_router)
17
+ router.include_router(resolve_router)
18
+ router.include_router(pipe_io_router)
19
+ router.include_router(codegen_router)
20
+ router.include_router(tools_router)
21
+ router.include_router(agent_router)
@@ -0,0 +1,11 @@
1
+ from fastapi import APIRouter
2
+
3
+ from .concept import router as concept_router
4
+ from .models import router as models_router
5
+ from .pipe_spec import router as pipe_spec_router
6
+
7
+ router = APIRouter()
8
+
9
+ router.include_router(models_router)
10
+ router.include_router(concept_router)
11
+ router.include_router(pipe_spec_router)
@@ -0,0 +1,60 @@
1
+ """Concept endpoint — convert JSON concept spec to TOML."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from fastapi import APIRouter
7
+ from pipelex.builder.operations.concept_ops import concept_spec_to_toml, parse_concept_spec
8
+ from pydantic import BaseModel, Field, ValidationError, field_validator
9
+
10
+ from pipelex_api.errors import raise_validation_error
11
+ from pipelex_api.json_body import JsonBodyRoute
12
+ from pipelex_api.limits import MAX_AGENT_SPEC_BYTES
13
+
14
+ router = APIRouter(tags=["agent"], route_class=JsonBodyRoute)
15
+
16
+
17
+ class BuildConceptRequest(BaseModel):
18
+ spec: dict[str, Any] = Field(..., description="JSON concept specification.")
19
+
20
+ @field_validator("spec")
21
+ @classmethod
22
+ def _bound_spec_size(cls, value: dict[str, Any]) -> dict[str, Any]:
23
+ if len(json.dumps(value).encode("utf-8")) > MAX_AGENT_SPEC_BYTES:
24
+ msg = f"spec exceeds {MAX_AGENT_SPEC_BYTES // 1024} KiB limit"
25
+ raise ValueError(msg)
26
+ return value
27
+
28
+
29
+ class BuildConceptResponse(BaseModel):
30
+ success: bool = Field(default=True, description="Whether the operation was successful")
31
+ concept_code: str = Field(..., description="The concept code that was generated")
32
+ toml: str = Field(..., description="Generated TOML content for the concept")
33
+
34
+
35
+ @router.post("/build/concept")
36
+ async def build_concept(request_data: BuildConceptRequest) -> BuildConceptResponse:
37
+ """Convert a JSON concept spec to TOML format.
38
+
39
+ A malformed spec surfaces as a Pydantic `ValidationError` — an API-owned
40
+ 422. Pipelex domain failures propagate untouched to the global
41
+ `PipelexError` handler in `pipelex_api.exception_handlers`.
42
+
43
+ A malformed shape — a non-mapping `structure` (`{"structure": "string"}`)
44
+ or a `structure` field that is neither a string nor a mapping
45
+ (`{"structure": {"f": 42}}`) — is refused by `parse_concept_spec` itself
46
+ with a typed `ConceptSpecError`, which that same handler answers. Nothing
47
+ broader is caught here: a bare exception from pipelex is the signal of a
48
+ real programming bug, and a route-level catch would mask it.
49
+ """
50
+ try:
51
+ concept_spec = parse_concept_spec(request_data.spec)
52
+ toml_content = concept_spec_to_toml(concept_spec)
53
+
54
+ return BuildConceptResponse(
55
+ success=True,
56
+ concept_code=concept_spec.concept_code,
57
+ toml=toml_content,
58
+ )
59
+ except ValidationError as exc:
60
+ raise_validation_error(message=str(exc))
@@ -0,0 +1,49 @@
1
+ """Models endpoint — the MTHDS Protocol model deck this runner routes to."""
2
+
3
+ from typing import Annotated
4
+
5
+ from fastapi import APIRouter, Query, Request
6
+ from mthds.protocol.models import ModelCategory
7
+ from pipelex.pipeline.runner import PipelexModelDeck
8
+
9
+ from pipelex_api.error_types import ErrorType
10
+ from pipelex_api.errors import raise_validation_error
11
+ from pipelex_api.routes.pipelex.pipeline import ApiRunner
12
+
13
+ router = APIRouter(tags=["agent"])
14
+
15
+
16
+ @router.get("/models", openapi_extra={"x-mthds-protocol": True})
17
+ async def get_models(
18
+ request: Request,
19
+ model_type: Annotated[
20
+ str | None,
21
+ Query(alias="type", description="Filter by model category: llm, extract, img_gen, search. Single value (protocol arity)."),
22
+ ] = None,
23
+ ) -> PipelexModelDeck:
24
+ """List the model deck this runner can route to (MTHDS Protocol `GET /models`).
25
+
26
+ Answers the protocol `ModelDeck` as produced by `PipelexMTHDSProtocol.models` —
27
+ the flat `models` list (`{name, type}` entries) plus this implementation's
28
+ category-keyed routing extensions (`aliases`, `waterfalls`). The `type` query
29
+ param is a SINGLE protocol `ModelCategory` value: repeated `?type=` values
30
+ (arity, `ValidationError`) and unknown categories (`InvalidModelCategory`) are
31
+ both 422s (RFC 7807).
32
+ """
33
+ # Protocol arity: `type` is a plain single-value enum. FastAPI silently keeps one of
34
+ # several repeated scalar query params, so the multi-value rejection must be explicit.
35
+ # Generic ValidationError, not InvalidModelCategory: the values may all be valid —
36
+ # what's wrong is the arity.
37
+ if len(request.query_params.getlist("type")) > 1:
38
+ raise_validation_error(message="The `type` query parameter accepts a single value")
39
+ category: ModelCategory | None = None
40
+ if model_type is not None:
41
+ try:
42
+ category = ModelCategory(model_type)
43
+ except ValueError:
44
+ valid = ", ".join(sorted(member.value for member in ModelCategory))
45
+ raise_validation_error(
46
+ message=f"Invalid model category. Valid values: {valid}",
47
+ error_type=ErrorType.INVALID_MODEL_CATEGORY,
48
+ )
49
+ return await ApiRunner().models(category=category)
@@ -0,0 +1,59 @@
1
+ """Pipe spec endpoint — convert JSON pipe spec to TOML."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from fastapi import APIRouter
7
+ from pipelex.builder.operations.pipe_ops import parse_pipe_spec, pipe_spec_to_toml
8
+ from pydantic import BaseModel, Field, ValidationError, field_validator
9
+
10
+ from pipelex_api.errors import raise_validation_error
11
+ from pipelex_api.json_body import JsonBodyRoute
12
+ from pipelex_api.limits import MAX_AGENT_SPEC_BYTES
13
+
14
+ router = APIRouter(tags=["agent"], route_class=JsonBodyRoute)
15
+
16
+
17
+ class BuildPipeSpecRequest(BaseModel):
18
+ pipe_type: str = Field(..., min_length=1, max_length=128, description="The pipe type (e.g. PipeLLM, PipeSequence, etc.).")
19
+ spec: dict[str, Any] = Field(..., description="JSON pipe specification.")
20
+
21
+ @field_validator("spec")
22
+ @classmethod
23
+ def _bound_spec_size(cls, value: dict[str, Any]) -> dict[str, Any]:
24
+ if len(json.dumps(value).encode("utf-8")) > MAX_AGENT_SPEC_BYTES:
25
+ msg = f"spec exceeds {MAX_AGENT_SPEC_BYTES // 1024} KiB limit"
26
+ raise ValueError(msg)
27
+ return value
28
+
29
+
30
+ class BuildPipeSpecResponse(BaseModel):
31
+ success: bool = Field(default=True, description="Whether the operation was successful")
32
+ pipe_code: str = Field(..., description="The pipe code that was generated")
33
+ pipe_type: str = Field(..., description="The pipe type")
34
+ toml: str = Field(..., description="Generated TOML content for the pipe")
35
+
36
+
37
+ @router.post("/build/pipe-spec")
38
+ async def build_pipe_spec(request_data: BuildPipeSpecRequest) -> BuildPipeSpecResponse:
39
+ """Convert a JSON pipe spec to TOML format.
40
+
41
+ Two caller-mistake paths surface as a 422 here: a `ValidationError` from
42
+ Pydantic when the spec shape doesn't match the chosen pipe type, and a
43
+ `ValueError` from `parse_pipe_spec` when `pipe_type` is not one of the
44
+ known pipe types (documented in `parse_pipe_spec`'s docstring; raised at
45
+ exactly one site). Pipelex domain failures propagate untouched to the
46
+ global `PipelexError` handler in `pipelex_api.exception_handlers`.
47
+ """
48
+ try:
49
+ pipe_spec = parse_pipe_spec(request_data.spec, pipe_type=request_data.pipe_type)
50
+ toml_content = pipe_spec_to_toml(pipe_spec)
51
+
52
+ return BuildPipeSpecResponse(
53
+ success=True,
54
+ pipe_code=pipe_spec.pipe_code,
55
+ pipe_type=request_data.pipe_type,
56
+ toml=toml_content,
57
+ )
58
+ except (ValidationError, ValueError) as exc:
59
+ raise_validation_error(message=str(exc))