pipelex-api 0.71.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pipelex_api/__init__.py +0 -0
- pipelex_api/api.toml +21 -0
- pipelex_api/api_config.py +144 -0
- pipelex_api/bundle.py +243 -0
- pipelex_api/disclosure.py +43 -0
- pipelex_api/error_types.py +80 -0
- pipelex_api/error_uri.py +45 -0
- pipelex_api/errors.py +130 -0
- pipelex_api/exception_handlers.py +693 -0
- pipelex_api/json_body.py +182 -0
- pipelex_api/limits.py +67 -0
- pipelex_api/main.py +221 -0
- pipelex_api/method_cache.py +241 -0
- pipelex_api/method_source.py +215 -0
- pipelex_api/middleware.py +209 -0
- pipelex_api/openapi_responses.py +186 -0
- pipelex_api/openapi_schema.py +83 -0
- pipelex_api/problem_document.py +134 -0
- pipelex_api/py.typed +0 -0
- pipelex_api/routes/__init__.py +23 -0
- pipelex_api/routes/health.py +24 -0
- pipelex_api/routes/pipelex/__init__.py +21 -0
- pipelex_api/routes/pipelex/agent/__init__.py +11 -0
- pipelex_api/routes/pipelex/agent/concept.py +60 -0
- pipelex_api/routes/pipelex/agent/models.py +49 -0
- pipelex_api/routes/pipelex/agent/pipe_spec.py +59 -0
- pipelex_api/routes/pipelex/build/__init__.py +11 -0
- pipelex_api/routes/pipelex/build/inputs.py +192 -0
- pipelex_api/routes/pipelex/build/output.py +163 -0
- pipelex_api/routes/pipelex/build/runner.py +236 -0
- pipelex_api/routes/pipelex/codegen.py +164 -0
- pipelex_api/routes/pipelex/crate_ops.py +331 -0
- pipelex_api/routes/pipelex/pipe_io.py +186 -0
- pipelex_api/routes/pipelex/pipeline.py +938 -0
- pipelex_api/routes/pipelex/resolve.py +81 -0
- pipelex_api/routes/pipelex/tools.py +111 -0
- pipelex_api/routes/pipelex/utils.py +6 -0
- pipelex_api/routes/pipelex/validate.py +473 -0
- pipelex_api/routes/version.py +51 -0
- pipelex_api/schemas/__init__.py +0 -0
- pipelex_api/schemas/models.py +653 -0
- pipelex_api/security.py +284 -0
- pipelex_api-0.71.0.dist-info/METADATA +188 -0
- pipelex_api-0.71.0.dist-info/RECORD +46 -0
- pipelex_api-0.71.0.dist-info/WHEEL +4 -0
- pipelex_api-0.71.0.dist-info/licenses/LICENSE +95 -0
|
@@ -0,0 +1,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
|
+
)
|