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,938 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ from contextlib import contextmanager
6
+ from pathlib import Path, PurePosixPath
7
+ from typing import TYPE_CHECKING, Annotated, Any, NamedTuple, cast
8
+
9
+ from fastapi import APIRouter, Depends, Request
10
+ from fastapi.responses import JSONResponse
11
+ from mthds.protocol.exceptions import PipelineRequestError
12
+ from pipelex.config import get_config, is_pipe_func_sandbox_hosted
13
+ from pipelex.core.pipes.pipe_output import PipeOutput
14
+ from pipelex.pipe_run.delivery_assignment import DeliveryAssignment, StorageTarget, WebhookTarget
15
+ from pipelex.pipe_run.pipe_run_protocol import PipeRunProtocol
16
+ from pipelex.pipeline.pipeline_response import PipelexRunResultExecute, PipelexRunResultStart, RunState
17
+ from pipelex.pipeline.pipeline_run_setup import pipeline_run_setup
18
+ from pipelex.pipeline.runner import PipelexMTHDSProtocol
19
+ from pipelex.reporting.usage_records import apply_tokens_usage_wire_shape
20
+ from pipelex.runtime_bridge.exceptions import MissingBundleValidatorError, MissingOrchestratorError
21
+ from pipelex.runtime_bridge.primitives.hydration import hydrate_working_memory
22
+ from pipelex.runtime_hub import get_bundle_validator_registry, get_orchestrator_registry
23
+ from pipelex.system.environment import get_required_env
24
+ from pipelex.system.storage_scope import LOCAL_STORAGE_SCOPE, SINGLE_TENANT_USER_ID, validate_storage_scope_within_read_scope
25
+ from pydantic import ValidationError
26
+ from typing_extensions import override
27
+
28
+ from pipelex_api.api_config import get_api_config, resolve_orchestration_mode
29
+ from pipelex_api.bundle import ParsedBundle, materialize_parsed, parse_bundle
30
+ from pipelex_api.error_types import ErrorType
31
+ from pipelex_api.errors import raise_bad_request, raise_forbidden, raise_validation_error
32
+ from pipelex_api.json_body import decode_json_body
33
+ from pipelex_api.method_source import fetched_method_source
34
+ from pipelex_api.middleware import request_id_of
35
+ from pipelex_api.openapi_responses import (
36
+ PROBLEM_400_START_REQUIRES_ASYNC,
37
+ PROBLEM_403_RUN_POLICY,
38
+ PROBLEM_404_METHOD_PACKAGE,
39
+ PROBLEM_409_DUPLICATE_RUN,
40
+ PROBLEM_429,
41
+ PROBLEM_501_ASYNC_NOT_ENABLED,
42
+ )
43
+ from pipelex_api.routes.pipelex.utils import get_current_iso_timestamp
44
+ from pipelex_api.schemas.models import (
45
+ PipelexApiExecuteRequest,
46
+ PipelexApiExecuteResponse,
47
+ PipelexApiStartRequest,
48
+ PipelexApiStartResponse,
49
+ PipelineApiExtras,
50
+ RunRequest,
51
+ )
52
+
53
+ if TYPE_CHECKING:
54
+ from collections.abc import Generator
55
+
56
+ from mthds.protocol.pipe_output import VariableMultiplicity
57
+ from mthds.protocol.pipeline_inputs import PipelineInputs
58
+ from mthds.protocol.working_memory import WorkingMemoryAbstract
59
+ from pipelex.base_exceptions import ErrorReport
60
+ from pipelex.core.memory.working_memory import WorkingMemory
61
+ from pipelex.methods.fetching import MethodProvenance
62
+ from pipelex.pipe_run.pipe_job import PipeJob
63
+ from pipelex.pipeline.validation_report import PipelexValidationReport
64
+ from pipelex.plugins.orchestrator_registry import OrchestratorProtocol
65
+ from pipelex.runtime_bridge.payloads import PipelexPipeRunOutput
66
+
67
+ from pipelex_api.security import RequestUser
68
+
69
+
70
+ router = APIRouter(tags=["run"])
71
+
72
+
73
+ def get_request_user_id(request: Request) -> str:
74
+ """The caller this run is attributed to, and the owner its storage is keyed by.
75
+
76
+ **There is no `anonymous` fallback any more, and its removal is the point.**
77
+ This used to return the literal `"anonymous"` whenever `request.state.user`
78
+ was unset, which is reached on every auth path that establishes no
79
+ per-caller identity. That string then became the first segment of every
80
+ storage key the run wrote, so a deployment serving many callers put all of
81
+ them in one namespace where each could read the others' outputs — and it
82
+ looked like a working request the whole way through.
83
+
84
+ An identity-bearing deployment cannot get here without a user: `verify_jwt`
85
+ and `verify_api_key` raise on a bad token, and `no_auth` now raises when
86
+ `TRUST_FORWARDED_IDENTITY_HEADERS` is on and no id was forwarded. So a
87
+ missing user means the deployment declared it has no user model
88
+ (`AUTH_MODE=none` with no trusted proxy, or the shared static key), which is
89
+ a single tenant by configuration rather than an unknown caller.
90
+ """
91
+ user: RequestUser | None = getattr(request.state, "user", None)
92
+ return user.user_id if user else SINGLE_TENANT_USER_ID
93
+
94
+
95
+ def _resolve_storage_scope(request: Request, *, requested: str | None) -> str:
96
+ """Where this run's bytes go — the host's value, or the caller's own id.
97
+
98
+ **Scope is data, not identity**, which is why it arrives in the BODY while
99
+ `user_id` arrives on a trusted header. The runtime needs to know where to
100
+ write, not who to trust, so a multi-tenant host computes this where it knows
101
+ its own tenancy (`<org>/<method>/<run>` on hosted Pipelex) and sends it.
102
+
103
+ The fallback is the CALLER'S OWN ID, deliberately, and not a shared constant.
104
+ This runner used to derive the prefix from `user_id` internally; removing
105
+ that coupling is the point of the change, but the safe default when a
106
+ deployment says nothing is still one namespace per caller. A shared literal
107
+ here would recreate the `anonymous/` bug in a new spelling — every caller of
108
+ an identity-bearing deployment writing into one namespace, each able to read
109
+ the others' outputs, and looking like a working request throughout.
110
+
111
+ A single-tenant deployment therefore gets `single-tenant/…` by configuration
112
+ rather than by accident, and a multi-tenant one that forgets to send a scope
113
+ still isolates its callers.
114
+ """
115
+ return requested or get_request_user_id(request)
116
+
117
+
118
+ def _resolve_read_scope(request: Request, *, requested: str | None) -> str | None:
119
+ """What this run may read — the host's value, the caller's own id, or everything.
120
+
121
+ A sent value is the host's and is taken as it is. The fallback mirrors the storage
122
+ scope's (design DR2 of the read-scope campaign): when the field is omitted, a deployment
123
+ that identifies its callers scopes the run to the caller's own id, which is also where
124
+ their writes land by default, and a single-tenant deployment runs unscoped. That default
125
+ bounds a caller only where the host writes the body: a caller reaching a `jwt`
126
+ deployment directly can send any read scope, as it can send any storage scope. On a
127
+ multi-tenant host that forgets the field, the fallback fails closed: the host's own
128
+ storage scope lies under no caller's id, so the run is refused rather than reading
129
+ across tenants.
130
+ """
131
+ if requested is not None:
132
+ return requested
133
+ user: RequestUser | None = getattr(request.state, "user", None)
134
+ return user.user_id if user else None
135
+
136
+
137
+ class _RunScopes(NamedTuple):
138
+ storage_scope: str
139
+ read_scope: str | None
140
+
141
+
142
+ def _resolve_run_scopes(request: Request, *, extras: PipelineApiExtras) -> _RunScopes:
143
+ """Resolve the run's storage and read scopes, and refuse a pair the run could not work under.
144
+
145
+ The runtime refuses a storage scope outside the read scope, and the local storage
146
+ sentinel beside any read scope, with a bare `ValueError` from inside the run, which
147
+ would answer 500. Checking the resolved pair here answers the caller's mistake with a
148
+ 422 naming the field, before any method is fetched or library loaded.
149
+ """
150
+ storage_scope = _resolve_storage_scope(request, requested=extras.storage_scope)
151
+ read_scope = _resolve_read_scope(request, requested=extras.read_scope)
152
+ if read_scope is not None:
153
+ if storage_scope == LOCAL_STORAGE_SCOPE:
154
+ raise_validation_error(
155
+ message=f"A run with a read_scope must pass its own storage_scope, not the local sentinel {LOCAL_STORAGE_SCOPE!r}.",
156
+ error_type=ErrorType.INVALID_READ_SCOPE,
157
+ )
158
+ try:
159
+ validate_storage_scope_within_read_scope(storage_scope=storage_scope, read_scope=read_scope)
160
+ except ValueError as exc:
161
+ origin = "sent" if extras.read_scope is not None else "defaulted to the caller's id"
162
+ raise_validation_error(
163
+ message=f"{exc} The read_scope {read_scope!r} was {origin}, and the storage_scope is {storage_scope!r}.",
164
+ error_type=ErrorType.INVALID_READ_SCOPE,
165
+ )
166
+ return _RunScopes(storage_scope=storage_scope, read_scope=read_scope)
167
+
168
+
169
+ def _completion_signature(pipeline_run_id: str) -> str:
170
+ """Compute the HMAC-SHA256 signature for an async completion callback.
171
+
172
+ The signer (this server) and the verifier (your callback receiver) must
173
+ share the same `COMPLETION_CALLBACK_SECRET`. The signature is per-run and
174
+ the secret never travels — only the one-way hash does.
175
+ """
176
+ secret = get_required_env("COMPLETION_CALLBACK_SECRET")
177
+ return hmac.new(
178
+ secret.encode("utf-8"),
179
+ pipeline_run_id.encode("utf-8"),
180
+ hashlib.sha256,
181
+ ).hexdigest()
182
+
183
+
184
+ def _pipe_output_from_run_output(run_output: PipelexPipeRunOutput) -> PipeOutput:
185
+ """Rehydrate an orchestrator's JSON-safe `PipelexPipeRunOutput` into a typed `PipeOutput`.
186
+
187
+ The `OrchestratorRegistry` answers with the JSON-safe boundary payload (the same shape
188
+ that crosses the Temporal worker boundary), produced by `serialize_completed_output`.
189
+ `/execute` returns the FULL output, so the synchronous path reverses that serialization
190
+ here, restoring the rich `PipeOutput` the base `execute` then wraps in
191
+ `PipelexRunResultExecute`:
192
+
193
+ - `working_memory` is rebuilt via `hydrate_working_memory` — the same routine the
194
+ Temporal workers use — so it must run while the run library (hence the scoped
195
+ `ClassRegistry`) is still open; the base `execute` keeps it open until after the run
196
+ returns, which is exactly this call site.
197
+ - `graph_spec` / `pipe_io_artifacts` / `tokens_usages` are validated back from their
198
+ `model_dump(mode="json")` dumps with `strict=False`: the orchestrator dumped them in JSON mode (e.g.
199
+ `GraphSpec.created_at` became an ISO string), and those models are `strict=True`, so a
200
+ strict re-validation would reject the string. `strict=False` is the correct tool for
201
+ reversing our own trusted JSON dump — it is a round-trip, not untrusted ingest.
202
+
203
+ Routing DIRECT through this serialize→rehydrate round-trip is intentional: it keeps
204
+ `/execute` on the SAME per-call dispatch seam as `/start` and `/validate` (no per-mode
205
+ branch), at the cost of one in-process re-serialization of the working memory.
206
+ """
207
+ return PipeOutput.model_validate(
208
+ {
209
+ "working_memory": hydrate_working_memory(run_output.output_dict),
210
+ "pipeline_run_id": run_output.pipeline_run_id,
211
+ "graph_spec": run_output.graph_spec_dump,
212
+ "graph_assembly_error": run_output.graph_assembly_error,
213
+ "pipe_io_artifacts": run_output.pipe_io_artifacts_dump,
214
+ "pipe_io_artifacts_error": run_output.pipe_io_artifacts_error,
215
+ "tokens_usages": run_output.tokens_usages_dump,
216
+ "usage_assembly_error": run_output.usage_assembly_error,
217
+ },
218
+ strict=False,
219
+ )
220
+
221
+
222
+ class _OrchestratorPipeRun(PipeRunProtocol):
223
+ """Adapts a mode-selected orchestrator to the `PipeRunProtocol` the base `execute` drives.
224
+
225
+ `ApiRunner.execute` injects one of these as `self._pipe_run` so the base `execute` retains
226
+ ALL of its run lifecycle (library setup/teardown, tracer close, pipeline-manager cleanup,
227
+ telemetry, error mapping) while the actual dispatch goes through the per-request
228
+ `orchestration_mode`'s orchestrator instead of the boot-global pipe-run slot. The
229
+ orchestrator's JSON-safe output is rehydrated back into the rich `PipeOutput` the base expects.
230
+
231
+ The blocking wait-semantics is intrinsic to `orchestrator.execute` (the protocol's BLOCKING arm —
232
+ it awaits completion and returns the completed-run `PipelexPipeRunOutput`); `/execute` is
233
+ synchronous and always drives this arm, so there is no delivery axis to thread here.
234
+ """
235
+
236
+ def __init__(self, *, orchestrator: OrchestratorProtocol) -> None:
237
+ self._orchestrator = orchestrator
238
+
239
+ @override
240
+ async def run(self, pipe_job: PipeJob, *, delivery_assignment: DeliveryAssignment | None = None) -> PipeOutput:
241
+ run_output = await self._orchestrator.execute(pipe_job=pipe_job, delivery_assignment=delivery_assignment)
242
+ return _pipe_output_from_run_output(run_output)
243
+
244
+
245
+ class ApiRunner(PipelexMTHDSProtocol):
246
+ """API runner that dispatches `execute`, `start`, and `validate` through the per-call plugin registries.
247
+
248
+ Every surface resolves the deployment's `orchestration_mode` (config default + optional
249
+ per-request override) and dispatches through a per-call hub registry: `execute` runs a
250
+ top-level pipe through the `OrchestratorRegistry`'s blocking `execute` arm and returns the
251
+ full output, `start` enqueues one through the same registry's fire-and-forget `start` arm,
252
+ `validate_verdict` produces a validation verdict through the `BundleValidatorRegistry`. On the
253
+ orchestrator-agnostic base that means `direct` in-process; a `temporal` mode dispatches to a
254
+ worker when `pipelex-temporal` is installed and selected. The base names no orchestrator and
255
+ imports no orchestrator SDK; a mode with no registered arm fails loud with the matching
256
+ `Missing*Error` (carrying the install hint). Delivery is endpoint-intrinsic — the caller never
257
+ chooses it; only the backend is resolved per request. Dispatch changes the BACKEND, never the
258
+ artifact shapes — every operation answers with the same canonical models as the local runtime.
259
+ """
260
+
261
+ @override
262
+ async def execute(
263
+ self,
264
+ pipe_code: str | None = None,
265
+ mthds_contents: list[str] | None = None,
266
+ inputs: PipelineInputs | WorkingMemoryAbstract[Any] | None = None,
267
+ output_name: str | None = None,
268
+ output_multiplicity: VariableMultiplicity | None = None,
269
+ dynamic_output_concept_ref: str | None = None,
270
+ extra: dict[str, Any] | None = None,
271
+ delivery_assignment: DeliveryAssignment | None = None,
272
+ request_id: str | None = None,
273
+ requested_orchestration_mode: str | None = None,
274
+ ) -> PipelexRunResultExecute:
275
+ """Execute a method synchronously, dispatching by the resolved `orchestration_mode`.
276
+
277
+ Symmetric with `start`: the effective `orchestration_mode` is resolved FIRST (config default
278
+ + optional per-request override, so a forbidden override is refused with a 403 before any
279
+ library load), then the run is dispatched through the hub's `OrchestratorRegistry` instead
280
+ of the boot-global pipe-run slot. `direct` runs in-process on this agnostic base; a
281
+ `temporal` mode dispatches the whole job to a worker and awaits it. A mode with no registered
282
+ orchestrator fails loud with `MissingOrchestratorError` (carrying the install hint).
283
+
284
+ `/execute` is synchronous — it returns the full output — so it drives the orchestrator's
285
+ blocking `execute` arm regardless of backend. Wait-semantics is endpoint-intrinsic, never
286
+ requestable, so there is nothing for the caller to get wrong here (the fire-and-forget arm
287
+ is `/start`'s, gated there by an honest capability check).
288
+
289
+ The orchestrator is injected as this runner's `_pipe_run` so the inherited base `execute`
290
+ keeps the entire run lifecycle (library setup/teardown, tracer close, pipeline-manager
291
+ cleanup, telemetry, error mapping); only the dispatch backend and the output rehydration
292
+ (`_OrchestratorPipeRun`) change. `request_id` is an API-layer extra threaded into
293
+ `RunMetadata.request_id` for log correlation, so it reaches the job a worker is handed.
294
+ `requested_orchestration_mode` is the optional per-request backend override
295
+ (`PipelineApiExtras.orchestration_mode`).
296
+ """
297
+ # Resolve the effective orchestration mode FIRST — a per-request override the deployment
298
+ # policy forbids is refused (403) here, before any library load / run registration. Mirrors start().
299
+ orchestration_mode = resolve_orchestration_mode(requested_orchestration_mode, config=get_api_config())
300
+ orchestrator = get_orchestrator_registry().get_optional(mode=orchestration_mode)
301
+ if orchestrator is None:
302
+ raise MissingOrchestratorError(mode=orchestration_mode)
303
+
304
+ # Dispatch the run through the mode-selected orchestrator by injecting it as this runner's
305
+ # PipeRun, then delegate to the base execute, which owns the full run lifecycle. The
306
+ # ApiRunner is constructed per request, so mutating _pipe_run here is request-scoped.
307
+ # `/execute` is synchronous, so it drives the orchestrator's BLOCKING `execute` arm.
308
+ self._pipe_run = _OrchestratorPipeRun(orchestrator=orchestrator)
309
+ return await super().execute(
310
+ pipe_code=pipe_code,
311
+ mthds_contents=mthds_contents,
312
+ inputs=inputs,
313
+ output_name=output_name,
314
+ output_multiplicity=output_multiplicity,
315
+ dynamic_output_concept_ref=dynamic_output_concept_ref,
316
+ extra=extra,
317
+ delivery_assignment=delivery_assignment,
318
+ request_id=request_id,
319
+ )
320
+
321
+ @override
322
+ async def start(
323
+ self,
324
+ pipe_code: str | None = None,
325
+ mthds_contents: list[str] | None = None,
326
+ inputs: PipelineInputs | WorkingMemoryAbstract[Any] | None = None,
327
+ output_name: str | None = None,
328
+ output_multiplicity: VariableMultiplicity | None = None,
329
+ dynamic_output_concept_ref: str | None = None,
330
+ extra: dict[str, Any] | None = None,
331
+ pipeline_run_id: str | None = None,
332
+ callback_urls: list[str] | None = None,
333
+ request_id: str | None = None,
334
+ requested_orchestration_mode: str | None = None,
335
+ ) -> PipelexRunResultStart:
336
+ """Start a method execution asynchronously without waiting for completion.
337
+
338
+ Dispatch is orchestrator-agnostic: the rich `PipeJob` is built locally (so
339
+ `request_id`, `output_multiplicity`, `dynamic_output_concept_ref`, the run
340
+ registration, and telemetry all survive) and then handed to the resolved
341
+ `orchestration_mode`'s orchestrator via its fire-and-forget `start` arm, through the
342
+ hub's `OrchestratorRegistry`. The base imports no `temporalio` / orchestrator SDK; the
343
+ async-capable Temporal arm (returning a `workflow_id` immediately) is contributed by the
344
+ `pipelex-temporal` plugin when installed.
345
+
346
+ `/start` is genuinely fire-and-forget, so it is HONEST about its capability: the resolved
347
+ mode's orchestrator is looked up and its `supports_fire_and_forget` checked BEFORE any
348
+ library load. A blocking-only orchestrator (the in-process `direct` base) cannot honor
349
+ async delivery, so it is refused with a 400 (`START_REQUIRES_ASYNC_ORCHESTRATION`) — use
350
+ `/execute` — rather than silently running blocking and acking. A mode with no registered
351
+ orchestrator fails loud with `MissingOrchestratorError` (carrying the install hint), also
352
+ before any library load.
353
+
354
+ `pipeline_run_id` is the client-supplied run identifier — this source-available runner
355
+ honors it (protocol: implementations MAY decline it, but then MUST 422;
356
+ we accept it, and `StartAck.pipeline_run_id` echoes it back as authoritative).
357
+ `callback_urls` is THIS server's extension (completion webhooks) — the wire
358
+ layer validates it (`PipelineApiExtras`) and passes it here by name.
359
+ `extra` is the protocol's generic extension slot; this runner's wire
360
+ extras are parsed by the route layer, so nothing reaches it — a
361
+ non-empty value is an in-process misuse and is rejected. `request_id`
362
+ is an API-layer extra threaded into `RunMetadata.request_id` for log
363
+ correlation, so it reaches the job a worker is handed. `requested_orchestration_mode` is the optional per-request backend override
364
+ (`PipelineApiExtras.orchestration_mode`); it is resolved against the deployment's
365
+ `api.toml` policy and a forbidden override is refused with a 403.
366
+ """
367
+ if extra:
368
+ msg = f"ApiRunner defines no extension args beyond its named ones; got {sorted(extra)}."
369
+ raise PipelineRequestError(msg)
370
+ # Resolve the effective orchestration mode FIRST — a per-request override the deployment
371
+ # policy forbids is refused (403) here. Then look up the orchestrator and check its async
372
+ # capability: `/start` is fire-and-forget, so a blocking-only orchestrator (direct on the
373
+ # agnostic base) is refused HONESTLY with a 400 instead of silently running blocking and
374
+ # acking. Both gates run BEFORE pipeline_run_setup so a doomed request never loads a library.
375
+ orchestration_mode = resolve_orchestration_mode(requested_orchestration_mode, config=get_api_config())
376
+ orchestrator = get_orchestrator_registry().get_optional(mode=orchestration_mode)
377
+ if orchestrator is None:
378
+ raise MissingOrchestratorError(mode=orchestration_mode)
379
+ if not orchestrator.supports_fire_and_forget:
380
+ msg = (
381
+ f"Orchestration mode '{orchestration_mode}' cannot honor fire-and-forget delivery: /start requires an "
382
+ f"async-capable orchestration, and this deployment has none. Use /execute (synchronous) instead."
383
+ )
384
+ raise_bad_request(msg, error_type=ErrorType.START_REQUIRES_ASYNC_ORCHESTRATION)
385
+ created_at = get_current_iso_timestamp()
386
+ pipelex_inputs: PipelineInputs | WorkingMemory | None = cast("PipelineInputs | WorkingMemory | None", inputs)
387
+
388
+ execution_config = self.execution_config or get_config().interpreter.pipeline_execution
389
+ # Wire and runtime share the `pipeline_run_id` name (master D1 as
390
+ # revised — the id rename was reversed).
391
+ pipe_job, resolved_pipeline_run_id, _ = await pipeline_run_setup(
392
+ execution_config=execution_config,
393
+ library_id=self.library_id,
394
+ library_dirs=self.library_dirs,
395
+ pipe_code=pipe_code,
396
+ mthds_contents=mthds_contents,
397
+ bundle_uris=self.bundle_uris,
398
+ inputs=pipelex_inputs,
399
+ output_name=output_name,
400
+ output_multiplicity=output_multiplicity,
401
+ dynamic_output_concept_ref=dynamic_output_concept_ref,
402
+ pipe_run_mode=self.pipe_run_mode,
403
+ user_id=self.user_id,
404
+ storage_scope=self.storage_scope,
405
+ read_scope=self.read_scope,
406
+ # The base `execute` threads this itself; `start` builds its job
407
+ # here, so a group left out of this call is dropped with no error
408
+ # and the run's spans lose their groups while the ack still says 202.
409
+ extras=self.extras,
410
+ pipeline_run_id=pipeline_run_id,
411
+ request_id=request_id,
412
+ )
413
+
414
+ delivery_assignment = DeliveryAssignment(
415
+ # NO `key_prefix` — the runtime owns the `results/` leaf.
416
+ #
417
+ # This used to say `key_prefix="results"`, from the layout where the
418
+ # executor built `{user_id}/{key_prefix}{pipeline_run_id}` and the
419
+ # caller supplied the leaf. It now builds
420
+ # `{storage_scope}/{key_prefix}results`, so passing it here wrote
421
+ # every run's output to `<scope>/results/results/` — valid, stable,
422
+ # and wrong, with nothing failing to say so.
423
+ #
424
+ # `key_prefix` remains the caller's slot for an EXTRA level between
425
+ # the scope and the leaf; it is not where the leaf itself comes from.
426
+ storage=StorageTarget(),
427
+ # The completion payload's wire fields (`pipeline_run_id`/`state`,
428
+ # plus the transitional `status` alias) are written per delivery by
429
+ # pipelex's DeliveryExecutor — they are reserved keys on
430
+ # WebhookTarget.payload, so nothing is injected here.
431
+ webhooks=[
432
+ WebhookTarget(
433
+ url=url,
434
+ headers={"X-Completion-Signature": _completion_signature(resolved_pipeline_run_id)},
435
+ )
436
+ for url in callback_urls
437
+ ]
438
+ if callback_urls
439
+ else [],
440
+ )
441
+
442
+ # Dispatch the locally-built job through the resolved mode's orchestrator (looked up and
443
+ # capability-checked above) via its fire-and-forget `start` arm — the same final dispatch
444
+ # `run_pipe_via_bridge` performs, but fed the rich PipeJob instead of the lossy
445
+ # `PipelexPipeRunInput` (which carries no request_id / output_multiplicity /
446
+ # dynamic_output_concept_ref and skips run registration + telemetry). `start` genuinely
447
+ # enqueues the job and returns a `PipelexPipeDispatchAck` (ids only) immediately.
448
+ dispatch_ack = await orchestrator.start(pipe_job=pipe_job, delivery_assignment=delivery_assignment)
449
+
450
+ return PipelexRunResultStart(
451
+ pipeline_run_id=resolved_pipeline_run_id,
452
+ created_at=created_at,
453
+ state=RunState.STARTED,
454
+ workflow_id=dispatch_ack.workflow_id,
455
+ )
456
+
457
+ async def validate_verdict(
458
+ self,
459
+ *,
460
+ mthds_contents: list[str],
461
+ mthds_sources: list[str] | None,
462
+ allow_signatures: bool,
463
+ requested_orchestration_mode: str | None,
464
+ graph_pipe_code: str | None,
465
+ ) -> PipelexValidationReport | ErrorReport:
466
+ """Validate MTHDS bundles, returning the verdict as a value (the route maps it to a 200).
467
+
468
+ Mode-aware, mirroring `start`: the effective `orchestration_mode` is resolved FIRST (config
469
+ default + optional per-request override, so a forbidden override is refused with a 403
470
+ before any library load), then dispatched through the hub's `BundleValidatorRegistry`.
471
+ `direct` validates in-process on this agnostic base; a `temporal` mode dispatches the
472
+ whole job to a worker (`pipelex-temporal`). A mode with no registered validator fails
473
+ loud with `MissingBundleValidatorError` (carrying the install hint). Validation is inherently
474
+ blocking, so there is no delivery axis here — the registry holds one validator per mode.
475
+
476
+ Returns the verdict, not a raise: the valid `PipelexValidationReport`, or the structured
477
+ `ErrorReport` an invalid bundle produces (carrying `validation_errors`). A genuine
478
+ no-verdict infra fault propagates to the global problem+json handler (5xx). The route
479
+ discriminates on `isinstance(verdict, PipelexValidationReport)`.
480
+
481
+ `mthds_sources` is the optional per-content source-threading hook: each source lands on
482
+ the corresponding `blueprint.source`, so the structured `validation_errors` on a failure —
483
+ and the `bundle_blueprint` on success — carry a real `source` instead of `None`. The route
484
+ maps a length mismatch to a 422 before we get here; `None` keeps the sourceless behavior.
485
+ `library_dirs` is host context the in-process arm needs; a dispatched arm ignores it (the
486
+ worker loads its own library). A bundle without a declared `main_pipe` validates fine and
487
+ simply carries `graph_spec=None` (D2 — no precondition).
488
+
489
+ `graph_pipe_code` is the pipe the best-effort graph is drawn from, resolved the way a run
490
+ resolves its entry pipe; `None` keeps the runtime's default, the primary blueprint's
491
+ `main_pipe`. The route passes a fetched package manifest's `main_pipe`, so the graph shows
492
+ the pipe a selector-less run by that address executes.
493
+ """
494
+ # Resolve the effective mode FIRST — a per-request override the deployment policy forbids
495
+ # is refused (403) here, before any validator dispatch / library load. Mirrors start().
496
+ orchestration_mode = resolve_orchestration_mode(requested_orchestration_mode, config=get_api_config())
497
+ validator = get_bundle_validator_registry().get_optional(mode=orchestration_mode)
498
+ if validator is None:
499
+ raise MissingBundleValidatorError(mode=orchestration_mode)
500
+ library_dirs = [Path(library_dir) for library_dir in self.library_dirs] if self.library_dirs else None
501
+ verdict = await validator.validate_bundles(
502
+ mthds_contents=mthds_contents,
503
+ mthds_sources=mthds_sources,
504
+ allow_signatures=allow_signatures,
505
+ library_dirs=library_dirs,
506
+ # A validation is not a run, but its dry runs and its `pipe_dry_run` event are still
507
+ # done for the caller this runner was built for — the user and groups a run would state.
508
+ caller_identity=self.caller_identity,
509
+ graph_pipe_code=graph_pipe_code,
510
+ )
511
+ # The core seam types its valid arm at the protocol-level ValidationReport (a leaf type)
512
+ # to stay import-acyclic in core; every registered validator in fact produces the canonical
513
+ # PipelexValidationReport. Recover the precise type here — the single narrowing point — so
514
+ # the route's `isinstance(verdict, PipelexValidationReport)` yields ErrorReport on the else arm.
515
+ return cast("PipelexValidationReport | ErrorReport", verdict)
516
+
517
+
518
+ # The object keys a kajson decoder reads as class markers: `__class__` and `__module__` name a class to
519
+ # import and instantiate, and kajson reserves every key starting with `__kajson` for itself. The same set
520
+ # the hosted platform refuses before it forwards a run body.
521
+ _RESERVED_OBJECT_KEYS = frozenset({"__class__", "__module__"})
522
+ _RESERVED_OBJECT_KEY_PREFIX = "__kajson"
523
+
524
+
525
+ def _find_reserved_object_key(payload: object) -> str | None:
526
+ """Return the first reserved object key found at any depth of a parsed JSON value, or `None`.
527
+
528
+ Walks every object key at every depth with an explicit stack, so a deeply nested body cannot
529
+ exhaust the interpreter's recursion budget here. Values are never inspected: a string that
530
+ merely contains `__class__` is data, not a marker.
531
+ """
532
+ stack: list[object] = [payload]
533
+ while stack:
534
+ current = stack.pop()
535
+ if isinstance(current, dict):
536
+ for key, value in cast("dict[str, object]", current).items():
537
+ if key in _RESERVED_OBJECT_KEYS or key.startswith(_RESERVED_OBJECT_KEY_PREFIX):
538
+ return key
539
+ stack.append(value)
540
+ elif isinstance(current, list):
541
+ stack.extend(cast("list[object]", current))
542
+ return None
543
+
544
+
545
+ def _decode_body(body: bytes) -> dict[str, Any]:
546
+ """Parse the run body as plain JSON and confirm it is an object free of class markers. Raises 422 if not.
547
+
548
+ The body is parsed as plain JSON by `decode_json_body`, never with kajson. kajson reads a
549
+ `{"__class__": ..., "__module__": ...}` object as an order to import that module and
550
+ instantiate that class, so decoding a caller's body with it let any caller run code on the
551
+ server (`subprocess.Popen` included) before a single field was validated. The MTHDS Protocol
552
+ wire is plain JSON, and no client sends class markers.
553
+
554
+ Parsing as plain data is not enough on its own: a marker accepted as an ordinary dict comes
555
+ back to life the moment the runtime round-trips the inputs through kajson. So a key a kajson
556
+ decoder treats as a marker is refused at any depth with a 422 `ReservedObjectKey`.
557
+
558
+ The parse is `decode_json_body`'s, so its failures are caller mistakes answered with a 422
559
+ `InvalidJSON` rather than a sanitized 500: a body nested deeper than `MAX_JSON_NESTING_DEPTH`,
560
+ refused before it is parsed whatever the interpreter and its stack, a body that is not UTF-8,
561
+ and one that is not JSON.
562
+ """
563
+ decoded = decode_json_body(body)
564
+ if not isinstance(decoded, dict):
565
+ raise_validation_error(
566
+ message="Request body must be a JSON object",
567
+ error_type=ErrorType.INVALID_JSON,
568
+ )
569
+ request_data = cast("dict[str, Any]", decoded)
570
+ reserved_key = _find_reserved_object_key(request_data)
571
+ if reserved_key is not None:
572
+ # The key is the caller's, as long as the body allows (any `__kajson…` key matches), and the
573
+ # detail is logged as a field: echo it bounded, as the correlation fields are.
574
+ shown_key = reserved_key if len(reserved_key) <= _MAX_CORRELATION_FIELD_LEN else f"{reserved_key[:_MAX_CORRELATION_FIELD_LEN]}…"
575
+ raise_validation_error(
576
+ message=(
577
+ f"Request body contains the reserved object key '{shown_key}'. A run request is plain JSON: "
578
+ f"no object in it may carry a '__class__' or '__module__' key, or a key starting with '{_RESERVED_OBJECT_KEY_PREFIX}'."
579
+ ),
580
+ error_type=ErrorType.RESERVED_OBJECT_KEY,
581
+ )
582
+ return request_data
583
+
584
+
585
+ # The `error_type` a 422 on one extras field carries. A field absent from this map
586
+ # (`pipeline_run_id`, `orchestration_mode`) answers with the generic `ValidationError`.
587
+ _EXTRAS_FIELD_ERROR_TYPES: dict[str, ErrorType] = {
588
+ "callback_urls": ErrorType.INVALID_CALLBACK_URLS,
589
+ "storage_scope": ErrorType.INVALID_STORAGE_SCOPE,
590
+ "read_scope": ErrorType.INVALID_READ_SCOPE,
591
+ "analytics_groups": ErrorType.INVALID_ANALYTICS_GROUPS,
592
+ }
593
+
594
+
595
+ def _extras_error_type(exc: ValidationError) -> ErrorType:
596
+ """Classify an extras failure by the field that failed, so a client can branch on `error_type`.
597
+
598
+ Every extras failure used to answer `InvalidCallbackUrls`, including a traversal in
599
+ `storage_scope` on a request that carried no callback at all. A body failing on
600
+ more than one field gets the generic `ValidationError`: naming one of them would
601
+ send the caller to fix that field and leave the other one for the next request.
602
+ """
603
+ failed_fields = {str(error["loc"][0]) for error in exc.errors() if error["loc"]}
604
+ if len(failed_fields) != 1:
605
+ return ErrorType.VALIDATION_ERROR
606
+ return _EXTRAS_FIELD_ERROR_TYPES.get(failed_fields.pop(), ErrorType.VALIDATION_ERROR)
607
+
608
+
609
+ def _validate_extras(request_data: dict[str, Any]) -> PipelineApiExtras:
610
+ """Validate the API-server-only fields `PipelineApiExtras` declares, read straight off the body.
611
+
612
+ The whole body is validated rather than a hand-copied subset of its keys: the model
613
+ ignores keys it does not declare, so a field added to it is read off the wire with no
614
+ second list to keep in step. Such a list would drop a forgotten key SILENTLY, and for
615
+ `storage_scope` that is worse than an error: the run falls back to the caller's own id
616
+ and writes to the wrong prefix while reporting success.
617
+ """
618
+ try:
619
+ return PipelineApiExtras.model_validate(request_data)
620
+ except ValidationError as exc:
621
+ raise_validation_error(
622
+ message=str(exc),
623
+ error_type=_extras_error_type(exc),
624
+ )
625
+
626
+
627
+ # Per-field bound applied at the request.state binding site so an oversized
628
+ # caller-supplied `pipe_code` cannot blow up the size of every record the request emits.
629
+ # `RunRequest.pipe_code` carries no Pydantic `max_length`; this is the
630
+ # narrow cap that protects the structured error log without changing the
631
+ # upstream type contract. 256 covers any realistic pipe code (kebab-case
632
+ # identifier, typically tens of chars) with headroom.
633
+ _MAX_CORRELATION_FIELD_LEN = 256
634
+
635
+
636
+ def _coerce_correlation_field(value: Any) -> str | None:
637
+ """Normalize a body-derived correlation identifier for `request.state` binding.
638
+
639
+ Returns `None` when the value is missing, empty, or non-string — so the
640
+ handler's `_pipe_code_of` / `_pipeline_run_id_of` getters see a uniform
641
+ `None` and the error record carries no attribute at all, rather than an
642
+ empty string a downstream query would read as a real value. Truncates
643
+ oversized strings to `_MAX_CORRELATION_FIELD_LEN` so a caller cannot
644
+ inflate every error record the request emits by sending a megabyte-long
645
+ pipe_code.
646
+ """
647
+ if not isinstance(value, str) or not value:
648
+ return None
649
+ return value[:_MAX_CORRELATION_FIELD_LEN]
650
+
651
+
652
+ async def _parse_request(request: Request) -> tuple[RunRequest, PipelineApiExtras]:
653
+ """Parse and validate the request body.
654
+
655
+ Splits the body into:
656
+ 1. The upstream `RunRequest` (pipe_code, mthds_contents, inputs, …),
657
+ parsed as plain JSON by `_decode_body`, which refuses class markers.
658
+ 2. `PipelineApiExtras` (pipeline_run_id, callback_urls, orchestration_mode,
659
+ storage_scope, analytics_groups) validated by Pydantic — callback_urls
660
+ are checked for scheme + private/loopback hosts to harden against SSRF.
661
+
662
+ Body size is capped upstream by `request_body_size_middleware`.
663
+ """
664
+ body = await request.body()
665
+ request_data = _decode_body(body)
666
+ # Bind body-derived correlation identifiers onto `request.state` as soon as
667
+ # the raw dict is in hand — before `_validate_extras` or `from_body`, so a
668
+ # later validation failure (a rejected callback URL, a Pydantic coercion
669
+ # error on a sibling field) still rides the identifiers the caller named.
670
+ # `_coerce_correlation_field` normalizes empty / non-string / oversized.
671
+ # Mirrors the `_set_request_user` pattern in `pipelex_api.security` (binding the
672
+ # earliest known identity onto the request).
673
+ request.state.pipe_code = _coerce_correlation_field(request_data.get("pipe_code"))
674
+ request.state.pipeline_run_id = _coerce_correlation_field(request_data.get("pipeline_run_id"))
675
+ extras = _validate_extras(request_data)
676
+ try:
677
+ run_request = RunRequest.from_body(request_data)
678
+ except (PipelineRequestError, ValidationError) as exc:
679
+ # `from_body` rejects a body where neither `pipe_code` nor
680
+ # `mthds_contents` is supplied (PipelineRequestError) and a body whose
681
+ # fields fail Pydantic coercion (ValidationError) — both are caller
682
+ # mistakes, not server faults, so they map to a 422 rather than
683
+ # escaping to the generic-500 fallback.
684
+ raise_validation_error(message=str(exc))
685
+ return run_request, extras
686
+
687
+
688
+ @contextmanager
689
+ def _bundle_run_source(run_request: RunRequest) -> Generator[tuple[list[str] | None, list[str] | None], None, None]:
690
+ """Resolve a run's `(mthds_contents, library_dirs)` from the request.
691
+
692
+ A method bundle KEEPS the proven run path rather than replacing it: its `.mthds`
693
+ text travels as `mthds_contents` — so `main_pipe` resolves exactly as it does for a
694
+ plain (non-bundle) run — and ONLY the non-`.mthds` files (custom PipeFunc `.py`,
695
+ `requirements.txt`) are materialized into a temp `library_dirs` entry, where the
696
+ load path reads them without importing them. This mirrors "a normal run, plus the Python", instead of
697
+ handing the engine a bare directory with no `mthds_contents` (which never resolves
698
+ `main_pipe`, since that is only derived from `mthds_contents`).
699
+
700
+ No bundle → yields the request's own `mthds_contents` and no library dir (the
701
+ classic path, unchanged). Bundle with no non-`.mthds` files → yields the `.mthds`
702
+ texts and no library dir. The temp dir (when created) is cleaned up on exit.
703
+
704
+ Security gate (decision 5): a bundle that ships custom Python (`.py`) is only
705
+ honored on a sandbox-hosted deployment, where the load path reads the source
706
+ without importing it and refuses, with a 403 `MethodStructuresRefusedError`, a
707
+ bundle whose Python declares a structure class; PipeFunc source is captured for
708
+ the sandbox. On a non-hosted deployment, running that code would import it
709
+ in-process — refused with a 403 rather than executing untrusted code.
710
+ """
711
+ if run_request.bundle_b64 is None and run_request.files is None:
712
+ yield run_request.mthds_contents, None
713
+ return
714
+ # Parse + guard in memory FIRST, then apply the sandbox-hosted gate BEFORE any disk write —
715
+ # a bundle destined for a 403 on a non-hosted deployment never touches the filesystem.
716
+ parsed = parse_bundle(bundle_b64=run_request.bundle_b64, files=run_request.files)
717
+ if parsed.has_python_sources and not is_pipe_func_sandbox_hosted():
718
+ msg = "This bundle ships custom Python (.py); running it requires a sandbox-hosted deployment."
719
+ raise_forbidden(message=msg, error_type=ErrorType.CUSTOM_CODE_REQUIRES_SANDBOX)
720
+ # Split: `.mthds` text → `mthds_contents` (the proven main_pipe path); everything else
721
+ # (`.py`, `requirements.txt`) → a temp `library_dirs` entry the load path source-captures.
722
+ mthds_contents: list[str] = []
723
+ other_entries: list[tuple[PurePosixPath, bytes]] = []
724
+ for relpath, content in parsed.entries:
725
+ if str(relpath).endswith(".mthds"):
726
+ try:
727
+ mthds_contents.append(content.decode("utf-8"))
728
+ except UnicodeDecodeError:
729
+ raise_validation_error(message=f"Bundle .mthds file '{relpath}' is not valid UTF-8.", error_type=ErrorType.INVALID_BUNDLE)
730
+ else:
731
+ other_entries.append((relpath, content))
732
+ if not mthds_contents:
733
+ raise_validation_error(message="Method bundle contains no .mthds file.", error_type=ErrorType.INVALID_BUNDLE)
734
+ if not other_entries:
735
+ yield mthds_contents, None
736
+ return
737
+ with materialize_parsed(ParsedBundle(entries=tuple(other_entries))) as bundle:
738
+ yield mthds_contents, [str(bundle.directory)]
739
+
740
+
741
+ class _ResolvedRunSource(NamedTuple):
742
+ """What the run routes hand the runner, whatever the request's source form was."""
743
+
744
+ mthds_contents: list[str] | None
745
+ library_dirs: list[str] | None
746
+ pipe_code: str | None
747
+ method_provenance: MethodProvenance | None
748
+
749
+
750
+ @contextmanager
751
+ def _run_source(run_request: RunRequest) -> Generator[_ResolvedRunSource, None, None]:
752
+ """Resolve the request's run source: a `method_ref` fetch, a bundle, or the inline contents.
753
+
754
+ A `method_ref` resolves through `pipelex_api.method_source.fetched_method_source` (fetch → locate →
755
+ execution-locus gate → materialize) into exactly the shape a bundle produces — `.mthds` text
756
+ as `mthds_contents`, non-`.mthds` files in a temp `library_dirs` entry — so the engine runs
757
+ the same proven path. The entry pipe defaults to the fetched manifest's `main_pipe`; a
758
+ request `pipe_code` overrides it. Provenance `(address, tag, commit_sha)` rides back so the
759
+ routes can put it on the response. The other two forms delegate to `_bundle_run_source`,
760
+ unchanged.
761
+ """
762
+ if run_request.method_ref is not None:
763
+ with fetched_method_source(run_request.method_ref) as fetched:
764
+ yield _ResolvedRunSource(
765
+ mthds_contents=fetched.mthds_contents,
766
+ library_dirs=fetched.library_dirs,
767
+ pipe_code=run_request.pipe_code or fetched.main_pipe,
768
+ method_provenance=fetched.provenance,
769
+ )
770
+ return
771
+ with _bundle_run_source(run_request) as (mthds_contents, library_dirs):
772
+ yield _ResolvedRunSource(
773
+ mthds_contents=mthds_contents,
774
+ library_dirs=library_dirs,
775
+ pipe_code=run_request.pipe_code,
776
+ method_provenance=None,
777
+ )
778
+
779
+
780
+ @router.post(
781
+ "/execute",
782
+ # Documented 200 = the run result with the WIRE-shaped `pipe_output`: the handler returns a
783
+ # `JSONResponse` built from a trimmed dump, so FastAPI never serializes through this model —
784
+ # it is purely what the artifact publishes, and it must match `apply_tokens_usage_wire_shape`.
785
+ response_model=PipelexApiExecuteResponse,
786
+ # On top of the composite router's shared 401/413/422/500: the policy 403s (a forbidden
787
+ # per-request `orchestration_mode` override, the custom-code sandbox gate, the structures
788
+ # refusal on a bundle or a fetched package), the `method_ref` package-not-found 404, and the provider
789
+ # rate-limit passthrough (429) — `/execute` is the only route that runs inference, so it is
790
+ # the only one that can be rate-limited upstream. NO 409: unlike `/start`, `/execute` takes
791
+ # no client-supplied `pipeline_run_id` (the base runner generates one per call), so a caller
792
+ # cannot collide with an in-flight run.
793
+ responses={403: PROBLEM_403_RUN_POLICY, 404: PROBLEM_404_METHOD_PACKAGE, 429: PROBLEM_429},
794
+ # Documented body = the protocol's RunRequest plus THIS server's own
795
+ # `orchestration_mode` extension (the route honors a per-request override). The
796
+ # body is read through the raw Request (plain JSON with the reserved-key
797
+ # refusal — see `_parse_request`), so FastAPI cannot infer a typed body parameter;
798
+ # document it explicitly so the committed OpenAPI artifact (and protocol
799
+ # conformance tooling) publishes the request schema. `responses=` and
800
+ # `openapi_extra` touch different members of the operation object, so both land.
801
+ openapi_extra={
802
+ "x-mthds-protocol": True,
803
+ "requestBody": {
804
+ "required": True,
805
+ "content": {"application/json": {"schema": PipelexApiExecuteRequest.model_json_schema()}},
806
+ },
807
+ },
808
+ )
809
+ async def execute(request: Request) -> JSONResponse:
810
+ """Execute a method synchronously and return its full output (MTHDS Protocol `POST /execute`).
811
+
812
+ The backend is selected by the resolved `orchestration_mode` (deployment default + optional
813
+ policy-gated per-request override via the `orchestration_mode` extra), symmetric with `/start` —
814
+ not by `boot_orchestrator`. `/execute` is synchronous, so it dispatches with `BLOCKING` delivery
815
+ regardless of backend (wait-semantics is endpoint-set, never requestable). Pipelex domain
816
+ failures propagate untouched: the global `PipelexError` handler in `pipelex_api.exception_handlers`
817
+ turns them into an RFC 7807 problem response.
818
+ """
819
+ run_request, extras = await _parse_request(request)
820
+ scopes = _resolve_run_scopes(request, extras=extras)
821
+ with _run_source(run_request) as source:
822
+ runner = ApiRunner(
823
+ user_id=get_request_user_id(request),
824
+ storage_scope=scopes.storage_scope,
825
+ read_scope=scopes.read_scope,
826
+ extras=extras.analytics_groups,
827
+ library_dirs=source.library_dirs,
828
+ )
829
+ response = await runner.execute(
830
+ pipe_code=source.pipe_code,
831
+ mthds_contents=source.mthds_contents,
832
+ inputs=run_request.inputs,
833
+ output_name=run_request.output_name,
834
+ output_multiplicity=run_request.output_multiplicity,
835
+ dynamic_output_concept_ref=run_request.dynamic_output_concept_ref,
836
+ request_id=request_id_of(request),
837
+ requested_orchestration_mode=extras.orchestration_mode,
838
+ )
839
+ # The response dump carries the full internal usage models on
840
+ # `pipe_output.tokens_usages`; the client boundary gets the trimmed
841
+ # `TokensUsageRecord` wire shape instead (pipelex owns the shape authority).
842
+ response_dump = response.model_dump(mode="json", serialize_as_any=True, by_alias=True)
843
+ apply_tokens_usage_wire_shape(response_dump, pipe_output=response.pipe_output)
844
+ # Provenance of a `method_ref` run (address, tag, resolved commit SHA) — attached only when
845
+ # the run was selected by reference, so the classic response stays byte-identical.
846
+ if source.method_provenance is not None:
847
+ response_dump["method_provenance"] = source.method_provenance.model_dump(mode="json")
848
+ return JSONResponse(content=response_dump)
849
+
850
+
851
+ @router.post(
852
+ "/start",
853
+ response_model=PipelexApiStartResponse,
854
+ status_code=202,
855
+ # On top of the composite router's shared 401/413/422/500. `/start` is fire-and-forget, so
856
+ # its extra failures are all about the backend's ability to honor that and about the
857
+ # client-supplied run id it (alone) accepts:
858
+ # 400 — the resolved orchestrator is blocking-only (the in-process `direct` base): refuse
859
+ # honestly rather than block-and-ack. Use `/execute`.
860
+ # 403 — a per-request `orchestration_mode` override the deployment forbids, the
861
+ # custom-code sandbox gate, or the structures refusal on a bundle or a fetched package.
862
+ # 404 — a `method_ref` whose repository holds no matching package.
863
+ # 409 — the submitted `pipeline_run_id` is still registered for an in-flight run.
864
+ # 501 — an async-capable deployment whose async execution is not enabled.
865
+ responses={
866
+ 400: PROBLEM_400_START_REQUIRES_ASYNC,
867
+ 403: PROBLEM_403_RUN_POLICY,
868
+ 404: PROBLEM_404_METHOD_PACKAGE,
869
+ 409: PROBLEM_409_DUPLICATE_RUN,
870
+ 501: PROBLEM_501_ASYNC_NOT_ENABLED,
871
+ },
872
+ # Documented body = the protocol's StartRequest plus THIS server's own
873
+ # extensions (callback_urls) — the protocol model no longer advertises
874
+ # implementation extensions, so the server documents what it implements.
875
+ # Raw-Request parsing prevents FastAPI from inferring it — see the
876
+ # /execute note.
877
+ openapi_extra={
878
+ "x-mthds-protocol": True,
879
+ "requestBody": {
880
+ "required": True,
881
+ "content": {"application/json": {"schema": PipelexApiStartRequest.model_json_schema()}},
882
+ },
883
+ },
884
+ )
885
+ async def start(
886
+ request: Request,
887
+ parsed: Annotated[tuple[RunRequest, PipelineApiExtras], Depends(_parse_request)],
888
+ ) -> PipelexApiStartResponse:
889
+ """Start a method run and return its pipeline_run_id with a 202 ack (MTHDS Protocol `POST /start`).
890
+
891
+ Answers `202 Accepted` with a `StartAck`. A client-supplied `pipeline_run_id` is
892
+ honored (the protocol lets an implementation decline it; this runner accepts it, and
893
+ `StartAck.pipeline_run_id` is always authoritative). Pipelex domain failures propagate untouched: the global
894
+ `PipelexError` handler in `pipelex_api.exception_handlers` turns them into an
895
+ RFC 7807 problem response.
896
+
897
+ Fire-and-forget is a property of THIS endpoint (its delivery axis), honored only by an
898
+ async-capable backend. A deployment configures the backend (`orchestration_mode`) once; `/start`
899
+ sets `FIRE_AND_FORGET` delivery and checks the resolved orchestrator can honor it. A Temporal
900
+ deployment (`orchestration_mode = "temporal"`) enqueues the run and returns immediately with a
901
+ `workflow_id`. On the orchestrator-agnostic base (`orchestration_mode = "direct"`, the default)
902
+ the in-process orchestrator is blocking-only, so `/start` is HONEST: it refuses with a `400`
903
+ (`StartRequiresAsyncOrchestration`) — use `/execute` — rather than silently blocking and acking.
904
+ The completion callback (`callback_urls` / storage delivery) fires on the async path.
905
+ """
906
+ run_request, extras = parsed
907
+ scopes = _resolve_run_scopes(request, extras=extras)
908
+ # The run source is materialized only for the synchronous setup phase: `start` builds the
909
+ # PipeJob (crate carrying the captured `python_sources`) before it enqueues, so the temp dir
910
+ # (a bundle's, or a fetched package's) is no longer needed once `start` returns — cleanup on
911
+ # context exit is safe for the async path.
912
+ with _run_source(run_request) as source:
913
+ runner = ApiRunner(
914
+ user_id=get_request_user_id(request),
915
+ storage_scope=scopes.storage_scope,
916
+ read_scope=scopes.read_scope,
917
+ extras=extras.analytics_groups,
918
+ library_dirs=source.library_dirs,
919
+ )
920
+ start_result = await runner.start(
921
+ pipe_code=source.pipe_code,
922
+ mthds_contents=source.mthds_contents,
923
+ inputs=run_request.inputs,
924
+ output_name=run_request.output_name,
925
+ output_multiplicity=run_request.output_multiplicity,
926
+ dynamic_output_concept_ref=run_request.dynamic_output_concept_ref,
927
+ pipeline_run_id=extras.pipeline_run_id,
928
+ callback_urls=extras.callback_urls,
929
+ request_id=request_id_of(request),
930
+ requested_orchestration_mode=extras.orchestration_mode,
931
+ )
932
+ # The ack plus this server's provenance extension: `(address, tag, commit_sha)` for a
933
+ # `method_ref` run, null otherwise — the caller (and, on hosted, the platform's Run row)
934
+ # learns exactly what was fetched and at which commit.
935
+ return PipelexApiStartResponse(
936
+ **dict(start_result),
937
+ method_provenance=source.method_provenance,
938
+ )