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,653 @@
|
|
|
1
|
+
"""Shared request/response models for API routes.
|
|
2
|
+
|
|
3
|
+
These are API-server-only models. As of the MTHDS Protocol unification the
|
|
4
|
+
protocol itself defines no request model (the SDK runners take the basic args
|
|
5
|
+
as named parameters), so a server that wants a typed request body — like this
|
|
6
|
+
one — owns it. `RunRequest` / `StartRequest` are defined here, not imported.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ipaddress import ip_address
|
|
12
|
+
from typing import Annotated, Any, Self
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
from mthds.protocol.exceptions import PipelineRequestError
|
|
16
|
+
from mthds.protocol.pipe_output import VariableMultiplicity
|
|
17
|
+
from mthds.protocol.pipeline_inputs import PipelineInputs
|
|
18
|
+
from pipelex.core.pipes.pipe_output import PipeOutput
|
|
19
|
+
from pipelex.methods.fetching import MethodProvenance
|
|
20
|
+
from pipelex.pipeline.pipeline_response import PipelexRunResultExecute, PipelexRunResultStart
|
|
21
|
+
from pipelex.reporting.usage_records import TokensUsageRecord
|
|
22
|
+
from pipelex.system.run_extras import validate_run_extras
|
|
23
|
+
from pipelex.system.storage_scope import validate_read_scope, validate_storage_scope
|
|
24
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
25
|
+
from pydantic.functional_validators import SkipValidation
|
|
26
|
+
|
|
27
|
+
from pipelex_api.limits import (
|
|
28
|
+
MAX_CALLBACK_URL_LEN,
|
|
29
|
+
MAX_CALLBACK_URLS,
|
|
30
|
+
MAX_METHOD_REF_LEN,
|
|
31
|
+
MAX_MTHDS_FILE_BYTES,
|
|
32
|
+
MAX_MTHDS_FILES_PER_REQUEST,
|
|
33
|
+
MAX_PIPE_CODE_LEN,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ensure_mthds_file_within_bytes_limit(content: str) -> None:
|
|
38
|
+
"""Enforce the per-file size bound shared by every route that accepts inline MTHDS content."""
|
|
39
|
+
if len(content.encode("utf-8")) > MAX_MTHDS_FILE_BYTES:
|
|
40
|
+
msg = f"MTHDS file exceeds {MAX_MTHDS_FILE_BYTES // 1024} KiB limit"
|
|
41
|
+
raise ValueError(msg)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RunRequest(BaseModel):
|
|
45
|
+
"""Body of `POST /execute` — this server's typed request model.
|
|
46
|
+
|
|
47
|
+
The MTHDS Protocol has no request model (`mthds` deleted `RunRequest`: the
|
|
48
|
+
request body is just the basic args the runner already takes as named
|
|
49
|
+
parameters). pipelex-api keeps a typed model so it can publish the request
|
|
50
|
+
schema in its OpenAPI artifact and parse the body once.
|
|
51
|
+
|
|
52
|
+
The declared fields are the protocol's **basic** arguments. The model is
|
|
53
|
+
deliberately open (`extra="allow"`): a caller may send extra request
|
|
54
|
+
properties, and they are kept rather than silently dropped. Openness is not
|
|
55
|
+
a waiver, though — an unknown key never satisfies the run-source
|
|
56
|
+
precondition. Under the layered extension policy (the Pipelex workspace
|
|
57
|
+
spec `docs/specs/pipelex-platform-api.md` → "Layered extension policy"), an
|
|
58
|
+
extension-borne method selector is resolved by the layer that owns it
|
|
59
|
+
BEFORE the request reaches this one, so a body arriving here with no source
|
|
60
|
+
this server understands is an error whatever else it carries.
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
pipe_code: Code of the pipe to execute.
|
|
64
|
+
mthds_contents: List of MTHDS bundle contents to load.
|
|
65
|
+
inputs: Inputs in PipelineInputs format, as plain JSON — Pydantic validation is
|
|
66
|
+
skipped to preserve the flexible format (strings, lists, objects); the runtime
|
|
67
|
+
shapes each value against the entry pipe's declared input.
|
|
68
|
+
output_name: Name of the output slot to write to.
|
|
69
|
+
output_multiplicity: Output multiplicity setting.
|
|
70
|
+
dynamic_output_concept_ref: Override for the dynamic output concept ref.
|
|
71
|
+
bundle_b64: PIPELEX-API EXTENSION — base64-encoded zip of the whole method
|
|
72
|
+
bundle (`.mthds` + `.py` + `structures/*.py` + `requirements.txt`). Lets a
|
|
73
|
+
caller ship custom PipeFunc Python alongside the method; materialized into a
|
|
74
|
+
temporary library directory before the run. Mutually exclusive with `files`.
|
|
75
|
+
files: PIPELEX-API EXTENSION — the bundle as a `{relative_path: text}` map (the
|
|
76
|
+
unzipped equivalent of `bundle_b64`). Mutually exclusive with `bundle_b64`.
|
|
77
|
+
method_ref: PIPELEX-API EXTENSION — run a published method by address instead of
|
|
78
|
+
inline source. Resolved by THIS runner (git fetch at the tag, package located
|
|
79
|
+
by manifest identity); mutually exclusive with `mthds_contents` and with a
|
|
80
|
+
method bundle. `pipe_code` may accompany it to override the manifest's
|
|
81
|
+
`main_pipe`.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
model_config = ConfigDict(extra="allow")
|
|
85
|
+
|
|
86
|
+
pipe_code: str | None = None
|
|
87
|
+
mthds_contents: list[str] | None = None
|
|
88
|
+
inputs: Annotated[PipelineInputs | None, SkipValidation] = None
|
|
89
|
+
output_name: str | None = None
|
|
90
|
+
output_multiplicity: VariableMultiplicity | None = None
|
|
91
|
+
dynamic_output_concept_ref: str | None = None
|
|
92
|
+
bundle_b64: str | None = Field(
|
|
93
|
+
default=None,
|
|
94
|
+
description=(
|
|
95
|
+
"PIPELEX-API EXTENSION (not part of the MTHDS Protocol) — base64-encoded zip of the whole "
|
|
96
|
+
"method bundle (`.mthds` + `.py` + `structures/*.py` + `requirements.txt`), materialized into a "
|
|
97
|
+
"temporary library directory before the run so custom PipeFunc Python travels with the method. "
|
|
98
|
+
"Mutually exclusive with `files`. Custom `.py` is only honored on a sandbox-hosted deployment."
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
files: dict[str, str] | None = Field(
|
|
102
|
+
default=None,
|
|
103
|
+
description=(
|
|
104
|
+
"PIPELEX-API EXTENSION (not part of the MTHDS Protocol) — the method bundle as a "
|
|
105
|
+
"`{relative_path: text}` map (the unzipped equivalent of `bundle_b64`). Mutually exclusive with `bundle_b64`."
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
method_ref: str | None = Field(
|
|
109
|
+
default=None,
|
|
110
|
+
min_length=1,
|
|
111
|
+
max_length=MAX_METHOD_REF_LEN,
|
|
112
|
+
description=(
|
|
113
|
+
"PIPELEX-API EXTENSION (not part of the MTHDS Protocol) — run a published method by reference instead of "
|
|
114
|
+
"inline source. Address form: `github.com/<owner>/<repo>[/<selector>][@<tag>]` (e.g. "
|
|
115
|
+
"`github.com/Pipelex/methods/documents@v0.1.0`) — resolved by THIS runner: the repository is fetched at the "
|
|
116
|
+
"tag (a bare address means the default branch at HEAD), the package is located by manifest identity, and "
|
|
117
|
+
"the resolved commit SHA is recorded as `method_provenance` on the response. Mutually exclusive with "
|
|
118
|
+
"`mthds_contents` and with a method bundle (`bundle_b64` / `files`); `pipe_code` may accompany it to "
|
|
119
|
+
"override the manifest's `main_pipe`. A package shipping custom Python is only honored on a sandbox-hosted "
|
|
120
|
+
"deployment, and a package declaring Python structure classes is always refused — hosted execution accepts "
|
|
121
|
+
"MTHDS concepts and sandboxed PipeFuncs, not in-process Python."
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@model_validator(mode="before")
|
|
126
|
+
@classmethod
|
|
127
|
+
def validate_request(cls, values: dict[str, Any]) -> dict[str, Any]:
|
|
128
|
+
# The protocol requires at least one of pipe_code / mthds_contents. A method bundle
|
|
129
|
+
# (`bundle_b64` / `files`) carries its own `.mthds` — so it satisfies the precondition
|
|
130
|
+
# on its own (the pipe to run comes from the bundle's `main_pipe`). Extension args carry
|
|
131
|
+
# NO waiver: see the class docstring and `_refuse_source_less_extension_body`, which
|
|
132
|
+
# authors the better message for that case where the raw body is still in hand.
|
|
133
|
+
has_bundle = values.get("bundle_b64") is not None or values.get("files") is not None
|
|
134
|
+
has_method_ref = values.get("method_ref") is not None
|
|
135
|
+
# A bundle carries its own `.mthds`; combining it with inline `mthds_contents` would load
|
|
136
|
+
# both into one library with no dedup, so a shared domain collides deep in the run with an
|
|
137
|
+
# opaque duplicate-domain error. Refuse the combination up front (a bundle + `pipe_code` — to
|
|
138
|
+
# pick which pipe in the bundle to run — is still fine).
|
|
139
|
+
if has_bundle and values.get("mthds_contents"):
|
|
140
|
+
msg = "A method bundle (bundle_b64 / files) and inline mthds_contents are mutually exclusive; send one or the other."
|
|
141
|
+
raise PipelineRequestError(msg)
|
|
142
|
+
# `method_ref` is a complete run source of its own (the fetched package carries its
|
|
143
|
+
# `.mthds` and its entry pipe): exactly one source per request, so it excludes both
|
|
144
|
+
# inline contents and a bundle. `pipe_code` beside it is fine — it overrides the
|
|
145
|
+
# manifest's `main_pipe` to pick which pipe in the fetched package to run.
|
|
146
|
+
if has_method_ref and values.get("mthds_contents"):
|
|
147
|
+
msg = "method_ref and inline mthds_contents are mutually exclusive; send one or the other."
|
|
148
|
+
raise PipelineRequestError(msg)
|
|
149
|
+
if has_method_ref and has_bundle:
|
|
150
|
+
msg = "method_ref and a method bundle (bundle_b64 / files) are mutually exclusive; send one or the other."
|
|
151
|
+
raise PipelineRequestError(msg)
|
|
152
|
+
if values.get("pipe_code") is None and not values.get("mthds_contents") and not has_bundle and not has_method_ref:
|
|
153
|
+
msg = (
|
|
154
|
+
"pipe_code and mthds_contents cannot both be empty. Either: both are provided, or if there are no mthds_contents, "
|
|
155
|
+
"then pipe_code must be provided and must reference a pipe already registered in the library. "
|
|
156
|
+
"If mthds_contents is provided but no pipe_code, the first content must have a main_pipe property."
|
|
157
|
+
)
|
|
158
|
+
raise PipelineRequestError(msg)
|
|
159
|
+
return values
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def _refuse_source_less_extension_body(cls, *, request_body: dict[str, Any], mthds_contents: list[str] | None) -> None:
|
|
163
|
+
"""Diagnose "a hosted client was pointed at this runner" before the generic precondition fires.
|
|
164
|
+
|
|
165
|
+
This lives here, and not in `validate_request`, because only the RAW body still
|
|
166
|
+
holds the keys the message needs to name: the model copies the declared fields
|
|
167
|
+
only, so by the time the validator runs the unknown keys are already gone.
|
|
168
|
+
|
|
169
|
+
Fires only when the body carries NO run source this server understands AND at
|
|
170
|
+
least one key it does not handle. A source-less body whose every key IS handled
|
|
171
|
+
falls through to `validate_request`'s base guidance — there is no extension to
|
|
172
|
+
name there. Wording stays deployment-neutral: the runner does not know whether
|
|
173
|
+
it is hosted, so it reports what it handles, never what the caller "should have"
|
|
174
|
+
deployed.
|
|
175
|
+
|
|
176
|
+
Precedence: `_parse_request` validates `PipelineApiExtras` before calling
|
|
177
|
+
`from_body`, so a body that is BOTH source-less and carries a malformed handled
|
|
178
|
+
extra (a non-http(s) `callback_urls` entry, say) gets the extras 422 and never
|
|
179
|
+
reaches this diagnostic. That ordering is deliberate — both are caller mistakes
|
|
180
|
+
answered with the same status and error domain, and demoting a concrete malformed
|
|
181
|
+
value to surface a second message would be the worse trade. The naming guarantee
|
|
182
|
+
therefore covers the single-fault case.
|
|
183
|
+
"""
|
|
184
|
+
has_bundle = request_body.get("bundle_b64") is not None or request_body.get("files") is not None
|
|
185
|
+
if request_body.get("pipe_code") is not None or mthds_contents or has_bundle or request_body.get("method_ref") is not None:
|
|
186
|
+
return
|
|
187
|
+
# `PipelineApiExtras` is defined below in this module — resolved at call time, which is
|
|
188
|
+
# always after import. Its fields are the API-server-only keys the routes DO handle —
|
|
189
|
+
# `_validate_extras` validates the body against this very model — so naming one of them
|
|
190
|
+
# as "not handled" would be a lie.
|
|
191
|
+
handled_keys = set(cls.model_fields) | set(PipelineApiExtras.model_fields) | {"mthds_content"}
|
|
192
|
+
unhandled_keys = sorted(key for key in request_body if key not in handled_keys)
|
|
193
|
+
if not unhandled_keys:
|
|
194
|
+
return
|
|
195
|
+
named = ", ".join(f"`{key}`" for key in unhandled_keys)
|
|
196
|
+
msg = (
|
|
197
|
+
"This request carries no run source this server understands (pipe_code, mthds_contents, "
|
|
198
|
+
f"or a method bundle). The extension args it does carry ({named}) are not handled by this "
|
|
199
|
+
"deployment — a hosted-only selector such as `method_id` must be sent to the hosted API, "
|
|
200
|
+
"which resolves it into a run source before any runner sees the request."
|
|
201
|
+
)
|
|
202
|
+
raise PipelineRequestError(msg)
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def from_body(cls, request_body: dict[str, Any]) -> RunRequest:
|
|
206
|
+
"""Build a RunRequest from the raw request-body dictionary.
|
|
207
|
+
|
|
208
|
+
Supports both the singular `mthds_content` (legacy) and plural
|
|
209
|
+
`mthds_contents`. `inputs` defaults to `{}` so a body that omits it
|
|
210
|
+
still parses.
|
|
211
|
+
"""
|
|
212
|
+
mthds_contents = request_body.get("mthds_contents")
|
|
213
|
+
if mthds_contents is None:
|
|
214
|
+
mthds_content = request_body.get("mthds_content")
|
|
215
|
+
if mthds_content is not None:
|
|
216
|
+
mthds_contents = [mthds_content]
|
|
217
|
+
cls._refuse_source_less_extension_body(request_body=request_body, mthds_contents=mthds_contents)
|
|
218
|
+
return cls(
|
|
219
|
+
pipe_code=request_body.get("pipe_code"),
|
|
220
|
+
mthds_contents=mthds_contents,
|
|
221
|
+
inputs=request_body.get("inputs", {}),
|
|
222
|
+
output_name=request_body.get("output_name"),
|
|
223
|
+
output_multiplicity=request_body.get("output_multiplicity"),
|
|
224
|
+
dynamic_output_concept_ref=request_body.get("dynamic_output_concept_ref"),
|
|
225
|
+
bundle_b64=request_body.get("bundle_b64"),
|
|
226
|
+
files=request_body.get("files"),
|
|
227
|
+
method_ref=request_body.get("method_ref"),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class StartRequest(RunRequest):
|
|
232
|
+
"""Body of `POST /start` — `RunRequest` plus the optional `pipeline_run_id`.
|
|
233
|
+
|
|
234
|
+
`pipeline_run_id` is the client-supplied run identifier; this source-available
|
|
235
|
+
runner accepts it (the server-generated id echoed in the start ack is always
|
|
236
|
+
authoritative). Extension args pass through `extra="allow"` exactly as on
|
|
237
|
+
`RunRequest`.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
pipeline_run_id: str | None = Field(default=None, max_length=128)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
_STORAGE_SCOPE_DESCRIPTION = (
|
|
244
|
+
"PIPELEX-API EXTENSION (not part of the MTHDS Protocol) — the host-supplied prefix every object "
|
|
245
|
+
"this run writes lands under. One to three path-safe segments (e.g. `tenant/run` or "
|
|
246
|
+
"`org/method/run`); the runtime composes its own leaves (`assets/`, `generated/`, `results/`, "
|
|
247
|
+
"`payloads/`) onto "
|
|
248
|
+
"it and never interprets the value. Omit it and the run is scoped to the caller's own id, which "
|
|
249
|
+
"is correct for a single-tenant deployment and wrong for a multi-tenant one — a host serving many "
|
|
250
|
+
"tenants MUST send this."
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
_READ_SCOPE_DESCRIPTION = (
|
|
254
|
+
"PIPELEX-API EXTENSION (not part of the MTHDS Protocol) — the host-supplied prefix every stored object "
|
|
255
|
+
"this run reads must lie under, and on a scoped run no local path is read at all. One to three "
|
|
256
|
+
"path-safe segments, which must be the `storage_scope` or one of its leading segments, since a run "
|
|
257
|
+
"reads back what it writes. Omit it and the run is scoped to the caller's own id when the deployment "
|
|
258
|
+
"identifies callers, and unscoped when it is single-tenant — a host serving many tenants MUST send this."
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
_ANALYTICS_GROUPS_DESCRIPTION = (
|
|
262
|
+
"PIPELEX-API EXTENSION (not part of the MTHDS Protocol) — the host-supplied groups this run's "
|
|
263
|
+
'telemetry belongs to, as a mapping of group type to group key (e.g. `{"organization": "org_acme"}`). '
|
|
264
|
+
"The runtime never reads a key by name: it carries the mapping on the run as its `extras` and stamps it on every span as "
|
|
265
|
+
"`pipelex.run.extras`, so every OpenTelemetry exporter receives it, and the deployment's own "
|
|
266
|
+
"PostHog stream, in `identified` mode, forwards it as the capture's groups. What Pipelex's own Gateway telemetry "
|
|
267
|
+
"stream receives is the runtime's decision, not this server's: the pinned runtime attributes it the same way, "
|
|
268
|
+
"with the run's `user_id` and these groups. Group types are lowercase snake_case starting with a letter, at most 32 characters; group keys "
|
|
269
|
+
"are 1 to 128 characters from `[A-Za-z0-9_-]`; at most five entries. Omit it and the run belongs to no "
|
|
270
|
+
"group, which is right for a single-tenant deployment."
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
_CALLER_ANALYTICS_GROUPS_DESCRIPTION = (
|
|
274
|
+
"PIPELEX-API EXTENSION (not part of the MTHDS Protocol) — the host-supplied groups the caller of this request "
|
|
275
|
+
'belongs to, as a mapping of group type to group key (e.g. `{"organization": "org_acme"}`), exactly as a run '
|
|
276
|
+
"request states them. The work this request does is not a run, but it still emits telemetry — the validation "
|
|
277
|
+
"sweep's `pipe_dry_run` event and its dry runs — and that telemetry is attributed to the caller: the "
|
|
278
|
+
"authenticated user and these groups. Same rules and same 422 `InvalidAnalyticsGroups` refusal as on "
|
|
279
|
+
"`/execute` and `/start`. Omit it and the caller belongs to no group."
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class CallerAnalyticsGroupsMixin(BaseModel):
|
|
284
|
+
"""The optional `analytics_groups` of a request whose work is done for a caller without being a run.
|
|
285
|
+
|
|
286
|
+
`/validate` and `/build/runner` dry-run the submitted pipes, and the runtime attributes that
|
|
287
|
+
telemetry to the caller it is handed (`pipelex.system.caller_identity.CallerIdentity`). The user
|
|
288
|
+
comes from the trusted auth layer, as on a run; the groups come from the body, as on a run, and are
|
|
289
|
+
refused at the wire with the runtime's own rules.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
analytics_groups: dict[str, str] | None = Field(default=None, description=_CALLER_ANALYTICS_GROUPS_DESCRIPTION)
|
|
293
|
+
|
|
294
|
+
@field_validator("analytics_groups")
|
|
295
|
+
@classmethod
|
|
296
|
+
def _validate_caller_analytics_groups(cls, value: dict[str, str] | None) -> dict[str, str] | None:
|
|
297
|
+
if value is None:
|
|
298
|
+
return None
|
|
299
|
+
return validate_run_extras(value=value)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
_ORCHESTRATION_MODE_DESCRIPTION = (
|
|
303
|
+
"PIPELEX-API EXTENSION (not part of the MTHDS Protocol) — request the orchestration mode (the backend) "
|
|
304
|
+
"for this run. An OPEN string token: `direct` (in-process, the base default), `temporal`, and any other "
|
|
305
|
+
"plugin-provided token are accepted; an unregistered token is refused at dispatch. The delivery axis "
|
|
306
|
+
"(blocking vs fire-and-forget) is endpoint-set, never requestable. Honored ONLY when the deployment sets "
|
|
307
|
+
"`allow_request_orchestration_mode_override = true` in its `api.toml`; otherwise a token that differs from "
|
|
308
|
+
"the deployment default is refused with a 403. Omit it to use the deployment default."
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
_ALLOWED_CALLBACK_SCHEMES = frozenset({"http", "https"})
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _is_disallowed_host(host: str) -> bool:
|
|
316
|
+
"""True if `host` looks like a private/loopback/link-local address.
|
|
317
|
+
|
|
318
|
+
Used to harden /start callback_urls against SSRF — a malicious
|
|
319
|
+
client could otherwise aim webhooks at internal services or cloud metadata
|
|
320
|
+
endpoints (e.g. 169.254.169.254). Best-effort: hostnames that resolve to
|
|
321
|
+
private addresses at fire time aren't blocked here, only literal IPs.
|
|
322
|
+
"""
|
|
323
|
+
if not host:
|
|
324
|
+
return True
|
|
325
|
+
if host in {"localhost", "metadata.google.internal", "metadata"}:
|
|
326
|
+
return True
|
|
327
|
+
try:
|
|
328
|
+
addr = ip_address(host)
|
|
329
|
+
except ValueError:
|
|
330
|
+
return False
|
|
331
|
+
return addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_multicast or addr.is_reserved or addr.is_unspecified
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class PipelineApiExtras(BaseModel):
|
|
335
|
+
"""Validates the API-server-only fields on `/execute` and `/start` requests.
|
|
336
|
+
|
|
337
|
+
`pipeline_run_id` is the protocol's optional start arg; `callback_urls` is
|
|
338
|
+
THIS server's extension (the MTHDS Protocol defines no completion channel —
|
|
339
|
+
extension args are defined and handled by the implementation that owns
|
|
340
|
+
them). The upstream protocol models don't know about `callback_urls`.
|
|
341
|
+
|
|
342
|
+
`storage_scope`, `read_scope` and `analytics_groups` are the host-computed run
|
|
343
|
+
context: data a multi-tenant host knows about its own tenancy and sends in the
|
|
344
|
+
body, as opposed to the caller's identity, which arrives on a trusted header. The
|
|
345
|
+
runtime carries them opaquely and validates them again at its own seam.
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
model_config = ConfigDict(extra="ignore")
|
|
349
|
+
|
|
350
|
+
pipeline_run_id: str | None = Field(default=None, max_length=128)
|
|
351
|
+
callback_urls: list[str] | None = Field(default=None, max_length=MAX_CALLBACK_URLS)
|
|
352
|
+
orchestration_mode: str | None = Field(default=None, description=_ORCHESTRATION_MODE_DESCRIPTION)
|
|
353
|
+
storage_scope: str | None = Field(default=None, description=_STORAGE_SCOPE_DESCRIPTION)
|
|
354
|
+
read_scope: str | None = Field(default=None, description=_READ_SCOPE_DESCRIPTION)
|
|
355
|
+
analytics_groups: dict[str, str] | None = Field(default=None, description=_ANALYTICS_GROUPS_DESCRIPTION)
|
|
356
|
+
|
|
357
|
+
@field_validator("read_scope")
|
|
358
|
+
@classmethod
|
|
359
|
+
def _validate_read_scope(cls, value: str | None) -> str | None:
|
|
360
|
+
"""Refuse a malformed read scope at the WIRE, with the runtime's own segment rule.
|
|
361
|
+
|
|
362
|
+
Its relation to the storage scope is checked once both are resolved, since either may
|
|
363
|
+
fall back to the caller's id (`pipelex_api.routes.pipelex.pipeline._resolve_run_scopes`).
|
|
364
|
+
"""
|
|
365
|
+
if value is None:
|
|
366
|
+
return None
|
|
367
|
+
return validate_read_scope(value=value)
|
|
368
|
+
|
|
369
|
+
@field_validator("storage_scope")
|
|
370
|
+
@classmethod
|
|
371
|
+
def _validate_storage_scope(cls, value: str | None) -> str | None:
|
|
372
|
+
"""Reject a traversal at the WIRE, not just at `JobMetadata`.
|
|
373
|
+
|
|
374
|
+
`validate_storage_scope` guards the runtime seam too, so this is a
|
|
375
|
+
second gate rather than the only one — but it is the one that answers a
|
|
376
|
+
bad request with a 422 naming the field, instead of a 500 from deep in
|
|
377
|
+
the run. The value becomes a storage key prefix, so a `..` in it escapes
|
|
378
|
+
the tenant it is supposed to confine.
|
|
379
|
+
"""
|
|
380
|
+
if value is None:
|
|
381
|
+
return None
|
|
382
|
+
return validate_storage_scope(value=value)
|
|
383
|
+
|
|
384
|
+
@field_validator("analytics_groups")
|
|
385
|
+
@classmethod
|
|
386
|
+
def _validate_analytics_groups(cls, value: dict[str, str] | None) -> dict[str, str] | None:
|
|
387
|
+
"""Refuse a malformed mapping at the WIRE, with the runtime's own rules.
|
|
388
|
+
|
|
389
|
+
The runtime validates the mapping again at the top of `pipeline_run_setup`
|
|
390
|
+
and on `RunMetadata`, so this is not the only gate. It is the one that
|
|
391
|
+
answers with a 422 naming the field before a method is fetched or a
|
|
392
|
+
library loaded. The rules are imported rather than restated: a second
|
|
393
|
+
spelling of the charset here would drift from the one the runtime
|
|
394
|
+
enforces, and the drift would surface as a 500 from inside the run.
|
|
395
|
+
"""
|
|
396
|
+
if value is None:
|
|
397
|
+
return None
|
|
398
|
+
return validate_run_extras(value=value)
|
|
399
|
+
|
|
400
|
+
@field_validator("callback_urls")
|
|
401
|
+
@classmethod
|
|
402
|
+
def _validate_callback_urls(cls, value: list[str] | None) -> list[str] | None:
|
|
403
|
+
if not value:
|
|
404
|
+
return value
|
|
405
|
+
for url in value:
|
|
406
|
+
if not url:
|
|
407
|
+
msg = "callback_urls must be non-empty strings"
|
|
408
|
+
raise ValueError(msg)
|
|
409
|
+
if len(url) > MAX_CALLBACK_URL_LEN:
|
|
410
|
+
msg = f"callback URL exceeds {MAX_CALLBACK_URL_LEN} chars"
|
|
411
|
+
raise ValueError(msg)
|
|
412
|
+
parsed = urlparse(url)
|
|
413
|
+
if parsed.scheme not in _ALLOWED_CALLBACK_SCHEMES:
|
|
414
|
+
msg = f"callback URL scheme must be http or https (got {parsed.scheme!r})"
|
|
415
|
+
raise ValueError(msg)
|
|
416
|
+
if _is_disallowed_host(parsed.hostname or ""):
|
|
417
|
+
msg = f"callback URL host {parsed.hostname!r} is not allowed (private/loopback/metadata addresses are blocked)"
|
|
418
|
+
raise ValueError(msg)
|
|
419
|
+
return value
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class PipelexApiStartRequest(StartRequest):
|
|
423
|
+
"""Documented body of `POST /start` — the protocol's `StartRequest` plus THIS server's extensions.
|
|
424
|
+
|
|
425
|
+
Used only to publish the OpenAPI request schema: the protocol model no
|
|
426
|
+
longer advertises implementation extensions, so this server documents the
|
|
427
|
+
ones it implements itself. Wire validation happens in `PipelineApiExtras`.
|
|
428
|
+
"""
|
|
429
|
+
|
|
430
|
+
callback_urls: list[str] | None = Field(
|
|
431
|
+
default=None,
|
|
432
|
+
description=(
|
|
433
|
+
"PIPELEX-API EXTENSION (not part of the MTHDS Protocol) — completion webhooks. "
|
|
434
|
+
"When the run finishes, the runner POSTs a completion notice to each URL, with an "
|
|
435
|
+
"X-Completion-Signature header holding the HMAC-SHA256 of the run id (not of the body). "
|
|
436
|
+
"The body carries `pipeline_run_id`, `state` (with the legacy `status` alias), `result_url` "
|
|
437
|
+
"(the storage key prefix of the stored results, when results were stored) and `error` on "
|
|
438
|
+
"failure; it does not carry the run's output. http/https only; private, loopback, link-local "
|
|
439
|
+
"and cloud-metadata hosts are rejected."
|
|
440
|
+
),
|
|
441
|
+
)
|
|
442
|
+
orchestration_mode: str | None = Field(default=None, description=_ORCHESTRATION_MODE_DESCRIPTION)
|
|
443
|
+
storage_scope: str | None = Field(default=None, description=_STORAGE_SCOPE_DESCRIPTION)
|
|
444
|
+
read_scope: str | None = Field(default=None, description=_READ_SCOPE_DESCRIPTION)
|
|
445
|
+
analytics_groups: dict[str, str] | None = Field(default=None, description=_ANALYTICS_GROUPS_DESCRIPTION)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class PipelexApiExecuteRequest(RunRequest):
|
|
449
|
+
"""Documented body of `POST /execute` — the protocol's `RunRequest` plus THIS server's run extensions.
|
|
450
|
+
|
|
451
|
+
Used only to publish the OpenAPI request schema: `/execute` reads the body through the raw
|
|
452
|
+
`Request` (plain JSON, parsed by the route), so FastAPI cannot infer the body type; this model documents the
|
|
453
|
+
extensions the route actually honors (`orchestration_mode`, `storage_scope`, `read_scope`, `analytics_groups`,
|
|
454
|
+
all parsed by `PipelineApiExtras`).
|
|
455
|
+
"""
|
|
456
|
+
|
|
457
|
+
orchestration_mode: str | None = Field(default=None, description=_ORCHESTRATION_MODE_DESCRIPTION)
|
|
458
|
+
storage_scope: str | None = Field(default=None, description=_STORAGE_SCOPE_DESCRIPTION)
|
|
459
|
+
read_scope: str | None = Field(default=None, description=_READ_SCOPE_DESCRIPTION)
|
|
460
|
+
analytics_groups: dict[str, str] | None = Field(default=None, description=_ANALYTICS_GROUPS_DESCRIPTION)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class PipeOutputWire(PipeOutput):
|
|
464
|
+
"""`PipeOutput` as it crosses the client boundary: usages trimmed to wire records.
|
|
465
|
+
|
|
466
|
+
The route applies pipelex's `apply_tokens_usage_wire_shape` to the response dump, so
|
|
467
|
+
`tokens_usages` carries flat `TokensUsageRecord`s — not the internal usage models with
|
|
468
|
+
their `job_metadata` plumbing and `unit_costs` rate table.
|
|
469
|
+
"""
|
|
470
|
+
|
|
471
|
+
# Narrows the inherited `list[AnyTokensUsage] | None`. `list` is invariant, so neither
|
|
472
|
+
# type checker accepts the override; this is a schema declaration, not an assignment a
|
|
473
|
+
# `cast()` could carry.
|
|
474
|
+
tokens_usages: list[TokensUsageRecord] | None = None # type: ignore[assignment]
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
_METHOD_PROVENANCE_DESCRIPTION = (
|
|
478
|
+
"PIPELEX-API EXTENSION (not part of the MTHDS Protocol) — provenance of a `method_ref` run: the package's "
|
|
479
|
+
"resolved full address, the requested tag (null for a bare address), and the commit SHA that was actually "
|
|
480
|
+
"fetched. The SHA is what keeps the run explainable when a tag moves. Absent (or null) for runs from inline "
|
|
481
|
+
"source or a bundle."
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class PipelexApiExecuteResponse(PipelexRunResultExecute):
|
|
486
|
+
"""Documented 200 body of `POST /execute` — the run result with the wire-shaped `pipe_output`.
|
|
487
|
+
|
|
488
|
+
Used only to publish the OpenAPI response schema. `/execute` returns a `JSONResponse`
|
|
489
|
+
built from the trimmed dump, so FastAPI never serializes through this model; declaring it
|
|
490
|
+
is what keeps the published artifact honest about what the route actually emits
|
|
491
|
+
(`method_provenance` is attached to the dump by the route for `method_ref` runs, and is
|
|
492
|
+
absent otherwise).
|
|
493
|
+
"""
|
|
494
|
+
|
|
495
|
+
pipe_output: PipeOutputWire # type: ignore[assignment]
|
|
496
|
+
method_provenance: MethodProvenance | None = Field(default=None, description=_METHOD_PROVENANCE_DESCRIPTION)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
class PipelexApiStartResponse(PipelexRunResultStart):
|
|
500
|
+
"""The 202 body of `POST /start` — the protocol's start ack plus this server's `method_provenance` extension.
|
|
501
|
+
|
|
502
|
+
Unlike its `/execute` counterpart this model IS what the route returns (FastAPI serializes
|
|
503
|
+
through it): the ack is small, so wrapping it costs nothing and keeps the artifact and the
|
|
504
|
+
wire in lockstep. `method_provenance` is populated for `method_ref` runs and null otherwise.
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
method_provenance: MethodProvenance | None = Field(default=None, description=_METHOD_PROVENANCE_DESCRIPTION)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
class MthdsFileItem(BaseModel):
|
|
511
|
+
"""One inline MTHDS bundle: its content plus an optional logical source for diagnostics.
|
|
512
|
+
|
|
513
|
+
The `files[]` envelope pairs each content with its source in one entry (the shape the codegen
|
|
514
|
+
spec pins for the resolve/codegen routes), unlike the legacy parallel
|
|
515
|
+
`mthds_contents[]`/`mthds_sources[]` lists on `/validate`.
|
|
516
|
+
"""
|
|
517
|
+
|
|
518
|
+
content: str = Field(..., description="MTHDS bundle content.")
|
|
519
|
+
source: str | None = Field(
|
|
520
|
+
default=None,
|
|
521
|
+
max_length=1024,
|
|
522
|
+
description=(
|
|
523
|
+
"Optional logical source for this bundle (e.g. its path relative to the submitted directory). "
|
|
524
|
+
"Threaded onto the blueprint so server-side diagnostics carry a `source` pointing at the owning file."
|
|
525
|
+
),
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
@field_validator("content")
|
|
529
|
+
@classmethod
|
|
530
|
+
def _bound_content(cls, value: str) -> str:
|
|
531
|
+
_ensure_mthds_file_within_bytes_limit(value)
|
|
532
|
+
return value
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
class MthdsFilesRequest(BaseModel):
|
|
536
|
+
"""Shared closure selector for the resolve/codegen routes: inline `files[]` XOR a `method_ref`.
|
|
537
|
+
|
|
538
|
+
Exactly one of the two must be provided (spec'd envelope) — both or neither is a request-shape
|
|
539
|
+
422. An **address-form** `method_ref` (`github.com/<owner>/<repo>[/<selector>][@<tag>]`) is
|
|
540
|
+
resolved by this server: the repository is fetched at the tag, the package is located by
|
|
541
|
+
manifest identity, and its `.mthds` files feed the closure with their real relative paths as
|
|
542
|
+
per-file sources. The **registry form** (any non-address reference) stays reserved and answers
|
|
543
|
+
501 until server-side method-registry resolution exists.
|
|
544
|
+
"""
|
|
545
|
+
|
|
546
|
+
files: list[MthdsFileItem] | None = Field(
|
|
547
|
+
default=None,
|
|
548
|
+
min_length=1,
|
|
549
|
+
max_length=MAX_MTHDS_FILES_PER_REQUEST,
|
|
550
|
+
description="Inline MTHDS bundles forming the closure to resolve (content-passing — no server-side path reads).",
|
|
551
|
+
)
|
|
552
|
+
method_ref: str | None = Field(
|
|
553
|
+
default=None,
|
|
554
|
+
min_length=1,
|
|
555
|
+
max_length=MAX_METHOD_REF_LEN,
|
|
556
|
+
description=(
|
|
557
|
+
"Reference to a published method, resolving to its package's `.mthds` files. Address form "
|
|
558
|
+
"(`github.com/<owner>/<repo>[/<selector>][@<tag>]`) is fetched and resolved server-side; the registry "
|
|
559
|
+
"form stays reserved (501 until a method registry exists)."
|
|
560
|
+
),
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
@model_validator(mode="after")
|
|
564
|
+
def _exactly_one_selector(self) -> Self:
|
|
565
|
+
if (self.files is None) == (self.method_ref is None):
|
|
566
|
+
msg = "provide exactly one of `files` or `method_ref`"
|
|
567
|
+
raise ValueError(msg)
|
|
568
|
+
return self
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class MthdsPipeRequest(MthdsFilesRequest):
|
|
572
|
+
"""Shared base for the per-pipe `/build/*` projections: the closure selector plus a pipe selector.
|
|
573
|
+
|
|
574
|
+
The pipe selector is the **qualified** ref `domain.pipe_code`, mirroring `pipelex codegen inputs
|
|
575
|
+
--pipe`. It is optional, and an omitted selector defaults with the run routes' precedence: on a
|
|
576
|
+
`method_ref` request, to the fetched package manifest's `main_pipe` (the package author's declared
|
|
577
|
+
entry pipe); otherwise to the closure's declared `main_pipe`, which is what a single-bundle caller
|
|
578
|
+
almost always wants. A closure that declares no `main_pipe` — or several, across domains — cannot
|
|
579
|
+
be defaulted from its own declarations, so an omitted selector is a request-shape 422 there when
|
|
580
|
+
no manifest `main_pipe` settles it (the same two arms the CLI rejects on).
|
|
581
|
+
"""
|
|
582
|
+
|
|
583
|
+
pipe_ref: str | None = Field(
|
|
584
|
+
default=None,
|
|
585
|
+
min_length=1,
|
|
586
|
+
max_length=MAX_PIPE_CODE_LEN,
|
|
587
|
+
description=(
|
|
588
|
+
"Qualified pipe ref (`domain.pipe_code`) to project. Optional — defaults to the fetched package manifest's "
|
|
589
|
+
"`main_pipe` on a `method_ref` request, else to the closure's declared `main_pipe`; a closure declaring none, "
|
|
590
|
+
"or several, with no manifest `main_pipe` to settle it, requires it explicitly."
|
|
591
|
+
),
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
ALLOW_SIGNATURES_DESCRIPTION = (
|
|
596
|
+
"When true, the validation sweep tolerates unimplemented pipe signatures instead of rejecting the "
|
|
597
|
+
"bundle (signatures dry-run trivially by minting a mock). Defaults to false (strict)."
|
|
598
|
+
)
|
|
599
|
+
"""Shared by the two routes that still run the dry-run sweep (`/validate`, `/build/runner`) — the flag only
|
|
600
|
+
parameterizes that sweep, so the static `/build/{inputs,output}` projections do not accept it."""
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
class MthdsContentsRequest(BaseModel):
|
|
604
|
+
"""The `POST /validate` request envelope: `mthds_contents` XOR `method_ref`, plus the sweep's `allow_signatures`.
|
|
605
|
+
|
|
606
|
+
This is the flat-list envelope. `/validate` uses it through this class (its `ValidateRequest`
|
|
607
|
+
subclass adds a parallel `mthds_sources` for per-file source labels), and the run routes
|
|
608
|
+
`/execute` and `/start` carry the same `mthds_contents` field independently on `RunRequest`.
|
|
609
|
+
The newer Pipelex-API extension routes (`/resolve`, `/codegen`, `/build/*`) use
|
|
610
|
+
`MthdsFilesRequest` instead (`files[]` pairing each content with its `source`, XOR a
|
|
611
|
+
`method_ref`), which folds the source label into each entry. `/validate` keeps the flat-list
|
|
612
|
+
shape deliberately: it is an MTHDS Protocol route, so changing its inline envelope is a
|
|
613
|
+
protocol-level decision owned by the `mthds/` spec, not this server's to take. `method_ref` is
|
|
614
|
+
this server's layer-2 extension beside that inline shape — an address is resolved by the
|
|
615
|
+
runner, so validate-by-address belongs here (the hosted-only `method_id` does not).
|
|
616
|
+
"""
|
|
617
|
+
|
|
618
|
+
mthds_contents: list[str] | None = Field(
|
|
619
|
+
default=None,
|
|
620
|
+
min_length=1,
|
|
621
|
+
max_length=MAX_MTHDS_FILES_PER_REQUEST,
|
|
622
|
+
description="MTHDS contents to load (always an array, even for a single file). Exactly one of `mthds_contents` / `method_ref`.",
|
|
623
|
+
)
|
|
624
|
+
method_ref: str | None = Field(
|
|
625
|
+
default=None,
|
|
626
|
+
min_length=1,
|
|
627
|
+
max_length=MAX_METHOD_REF_LEN,
|
|
628
|
+
description=(
|
|
629
|
+
"PIPELEX-API EXTENSION (not part of the MTHDS Protocol) — validate a published method by reference "
|
|
630
|
+
"instead of inline contents. Address form: `github.com/<owner>/<repo>[/<selector>][@<tag>]`, resolved by "
|
|
631
|
+
"THIS runner through the same fetch path as a `method_ref` run; the package's `.mthds` files feed the "
|
|
632
|
+
"validation with their real relative paths as per-file sources. Exactly one of `mthds_contents` / "
|
|
633
|
+
"`method_ref`. A resolution failure (fetch, package location, the custom-Python policy) is a non-2xx "
|
|
634
|
+
"`problem+json` — never an `is_valid: false` verdict, which is reserved for actual MTHDS content."
|
|
635
|
+
),
|
|
636
|
+
)
|
|
637
|
+
allow_signatures: bool = Field(default=False, description=ALLOW_SIGNATURES_DESCRIPTION)
|
|
638
|
+
|
|
639
|
+
@field_validator("mthds_contents")
|
|
640
|
+
@classmethod
|
|
641
|
+
def _bound_each_file(cls, value: list[str] | None) -> list[str] | None:
|
|
642
|
+
for content in value or []:
|
|
643
|
+
_ensure_mthds_file_within_bytes_limit(content)
|
|
644
|
+
return value
|
|
645
|
+
|
|
646
|
+
@model_validator(mode="after")
|
|
647
|
+
def _exactly_one_content_selector(self) -> Self:
|
|
648
|
+
# Strict XOR (the addressing design's tooling-route rule): a stateless diagnostic call
|
|
649
|
+
# has no linkage slot, so a second selector could only be ignored — refuse it instead.
|
|
650
|
+
if (self.mthds_contents is None) == (self.method_ref is None):
|
|
651
|
+
msg = "provide exactly one of `mthds_contents` or `method_ref`"
|
|
652
|
+
raise ValueError(msg)
|
|
653
|
+
return self
|