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.
- pipelex_api/__init__.py +0 -0
- pipelex_api/api.toml +21 -0
- pipelex_api/api_config.py +144 -0
- pipelex_api/bundle.py +243 -0
- pipelex_api/disclosure.py +43 -0
- pipelex_api/error_types.py +80 -0
- pipelex_api/error_uri.py +45 -0
- pipelex_api/errors.py +130 -0
- pipelex_api/exception_handlers.py +693 -0
- pipelex_api/json_body.py +182 -0
- pipelex_api/limits.py +67 -0
- pipelex_api/main.py +221 -0
- pipelex_api/method_cache.py +241 -0
- pipelex_api/method_source.py +215 -0
- pipelex_api/middleware.py +209 -0
- pipelex_api/openapi_responses.py +186 -0
- pipelex_api/openapi_schema.py +83 -0
- pipelex_api/problem_document.py +134 -0
- pipelex_api/py.typed +0 -0
- pipelex_api/routes/__init__.py +23 -0
- pipelex_api/routes/health.py +24 -0
- pipelex_api/routes/pipelex/__init__.py +21 -0
- pipelex_api/routes/pipelex/agent/__init__.py +11 -0
- pipelex_api/routes/pipelex/agent/concept.py +60 -0
- pipelex_api/routes/pipelex/agent/models.py +49 -0
- pipelex_api/routes/pipelex/agent/pipe_spec.py +59 -0
- pipelex_api/routes/pipelex/build/__init__.py +11 -0
- pipelex_api/routes/pipelex/build/inputs.py +192 -0
- pipelex_api/routes/pipelex/build/output.py +163 -0
- pipelex_api/routes/pipelex/build/runner.py +236 -0
- pipelex_api/routes/pipelex/codegen.py +164 -0
- pipelex_api/routes/pipelex/crate_ops.py +331 -0
- pipelex_api/routes/pipelex/pipe_io.py +186 -0
- pipelex_api/routes/pipelex/pipeline.py +938 -0
- pipelex_api/routes/pipelex/resolve.py +81 -0
- pipelex_api/routes/pipelex/tools.py +111 -0
- pipelex_api/routes/pipelex/utils.py +6 -0
- pipelex_api/routes/pipelex/validate.py +473 -0
- pipelex_api/routes/version.py +51 -0
- pipelex_api/schemas/__init__.py +0 -0
- pipelex_api/schemas/models.py +653 -0
- pipelex_api/security.py +284 -0
- pipelex_api-0.71.0.dist-info/METADATA +188 -0
- pipelex_api-0.71.0.dist-info/RECORD +46 -0
- pipelex_api-0.71.0.dist-info/WHEEL +4 -0
- 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))
|