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,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